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

41 KiB

spec_link.md - what surrounds the UART in the $E000 opponent module

Reference for the SwiftLink replacement of the bit-banged user-port UART in disassembly/game/modemDriverE000.s. Everything here was read out of the listings in disassembly/game/ (mainly modemDriverE000.s and mainProgram0800.s); every address quoted was checked against them. Nothing in this document proposes changing the layers above the UART - the point of the exercise is that they stay bit-for-bit the same so the two builds remain wire compatible at the same line speed.

Read docs/overview.md section 9 first for the shape of the module.

0. The layer boundary

swiftlink/checkAbi.py freezes three regions: $E000-$E017 (the jump table), $E01D-$E047 (variables the game reads and writes) and $EC00-$EFFF (utilities the map generator overlay calls). The rest is ours, but only the following is genuinely hardware specific.

Range Routine(s) Bytes Keep the entry address?
$E534-$E53F flushUartTxRing 12 yes - called from $E791 and $E80F
$E540-$E573 configureUserPortLines 52 yes - called from $E2D5 and $E387
$E574-$E57A dropDtrLine 7 yes - called from $E38D
$E57B-$E585 installCommNmiVector 11 yes - called from $E2CC
$E586-$E592 restartUart 13 yes - called from $E2CF and $E5E1
$E593-$E5B5 setCiaNmiMask 35 internal (only $E590 fall-through and $E5CA)
$E5B6-$E5C0 clearUartState + its 6 inline data bytes 11 internal
$E5C1-$E5C2 suspendUartIfRunning 2 internal ($E5DD)
$E5C3-$E5D5 stopCommNmi 19 yes - called from $E2AB
$E5D6-$E5FE serviceCarrierAndSuspendRequest 41 yes - called from $E3CB
$E5FF-$E606 rearmCarrierTimer 8 internal
$E607-$E653 startNextTxChar 77 internal ($E604 fall-through, $E673)
$E654-$E662 setBitPeriodFull / setBitPeriod 15 internal
$E663-$E684 nmiStartNextChar 34 internal
$E685-$E732 commNmiHandler 174 internal (reached through $FFFA/$FFFB)
$E73C-$E742 queueByteForTransmit 7 yes - called from $E7D2, $E81D, $E87C, $E92A

That is $E534-$E732 plus $E73C-$E742, 518 bytes in all. Six 6551 registers replace all of it, so there will be a lot of padding.

Everything else in the module is untouched by the hardware change:

  • the four ring buffers and their push/pop routines, $E40F-$E533 (the two "UART" rings are just memory; only their producer and consumer change);
  • the three indirect link-I/O vectors $E3B6/$E3B9/$E3BC and the table at $E3BF;
  • the frame/ARQ layer $E909-$EAB2 and its state block $EAB7-$EB02;
  • the packet/chat layer $E111-$E2FF.

Three data bytes are the interface between the baud table and the UART: $E056, $E057 and $E058 (section 4). Their meaning is private to the module, so the SwiftLink build may redefine them.

The single most important software contract across the boundary is uartPendingCount at $E0A5: the number of characters handed to the transmitter that have not physically left the machine. Four places above the UART read it and will misbehave if it lies:

  • $E76B serviceLinkTick compares it with frameInFlightFlag $E0A4 to decide that a queued packet has drained and a new one may be built;
  • $E82D continueByteSync waits for it to reach 0 before sending the next $00 sync byte;
  • $E860 servicePacketTransmit refuses to start anything new while it is 2 or more;
  • $E539 flushUartTxRing resets it from txCharActive $E5BB.

queueByteForTransmit $E73C increments it; the stock NMI decrements it at $E6B0 when a character finishes and zeroes it at $E634 if the ring unexpectedly runs dry. A 6551 build must keep the same accounting, counting a byte as pending until TDRE says the transmit register has taken it.

$E044 txPaceCounter is a transmitter throttle owned by the UART layer, but beginByteSyncPhase reaches into it: $E820-$E825 ORs bit 1 in so that at least two transmit slots separate the sync bytes. The replacement must tolerate that write (and may simply ignore the byte).

1. The suspend/resume handshake ($E039 request, $E03A acknowledge)

The fast loader ($0804 and the drive code) transfers bytes with a two-bit handshake on CIA2 port A timed against $D012. It cannot survive an NMI, so before any disk access the game parks the opponent module and waits for it to confirm.

Game side

suspendCommModule $0F5F:

0F5F  pha                 ; A must survive
0F60  lda $E03B           ; isLinkActive - no link (or the trainer) means nothing to suspend
0F63  beq $0F6F
0F65  lda #$C0            ; the request code
0F67  sta $E039           ; nmiSuspendRequest
0F6A  lda $E03A           ; nmiSuspendAck ...
0F6D  bpl $0F6A           ; ... spin until bit 7 is SET
0F6F  pla
0F70  rts

resumeCommModule $0F71 is the mirror image: it stores $00 into $E039 at $0F78 and spins at $0F7B until bit 7 of $E03A is clear.

Both are reached through suspendCommForDiskAccess $10CB and resumeCommAfterDiskAccess $10D1, which also put "WORKING..." on the status line. Callers: $0B3D, $1030, $103E, $1044, $1086, $10A8, $10DC in the main program and $7B7D, $7DF7, $7DFE, $7F57, $7F95, $815E, $81A2, $81AF in the map-generator overlay. waitForGameDiskInserted $10DC suspends and deliberately leaves the module suspended for its caller.

Exact values

Byte Value Meaning
$E039 nmiSuspendRequest $00 no request - run normally
$C0 the game wants the module's interrupts off (only bit 7 is ever tested)
$80 written by the module itself when it parks (see below)
$E03A nmiSuspendAck $00 interrupts are running
$80 interrupts are off; the disk may be touched

Only bit 7 matters on both bytes. $E5DD tests the request with BMI, and $0F6D/$0F7E test the acknowledge with BPL/BMI.

Module side, and where it runs

The handshake is served from pollLinkStatus $E00C -> pollCarrierState $E3CB -> serviceCarrierAndSuspendRequest $E5D6, which the raster IRQ calls once per frame at $112C (right after the $E000 X=0 tick at $1129). The game's spin loops therefore depend on the raster IRQ still running; the module never answers from the store itself. (The solo trainer answers from its $E000 X=0 entry instead, at $E059-$E05C of trainerAiE000.s, by copying $E039 straight into $E03A. Either entry is acceptable - the raster IRQ calls both, in that order.)

The stock sequence at $E5D6:

E5D6  lda $E03A       ; acknowledge
E5D9  asl a           ; C = "already suspended"
E5DA  lda $E039       ; request
E5DD  bmi $E5C1       ; bit 7 set -> suspendUartIfRunning
E5DF  bcc $E5E4       ; no request and not suspended -> carry on to the carrier sample
E5E1  jsr $E586       ; no request but still suspended -> restartUart
E5E4  ...             ; carrier sampling (section 2)

suspendUartIfRunning $E5C1 is a two-byte BCS $E5D5 that returns at once when the suspend has already been acknowledged, otherwise it falls into stopCommNmi $E5C3.

stopCommNmi does three things: writes $FF to uartRestartRequest $E042 (which forces connectionPhase $E040 back to 0 on the next tick of the modem state machine), calls setCiaNmiMask with $7F to disable every CIA2 interrupt source, and decrements uartPendingCount $E0A5 if a character was half way out of the transmitter, because that character will never finish.

setCiaNmiMask $E593 is where the two handshake bytes are actually written:

E5A9  eor #$FF        ; $92/$83 -> $00, $7F -> $80
E5AB  and #$80
E5AD  sta $E039       ; nmiSuspendRequest
E5B0  sta $E03A       ; nmiSuspendAck

so an enable mask clears both bytes and the disable mask sets both to $80. Note that the module overwrites the game's $C0 request with its own $80; that is deliberate and harmless, because on the next frame $E5DD still sees bit 7 set and $E5C1 short-circuits.

restartUart $E586 is the resume path: it zeroes the six bit-level UART state bytes $E5BB-$E5C0, writes 1 to uartRestartRequest $E042 ("re-evaluate the connection but keep the phase"), primes carrierSampleTimer $E045 with a negative value, and falls into setCiaNmiMask with $92, which clears both handshake bytes. restartUart is also the cold-start path, called from openCommLink at $E2CF.

On suspend ($E039 bit 7 set and $E03A bit 7 clear):

  1. Silence the ACIA completely. The expansion-port NMI is non-maskable, so it is not enough to set SEI; the interrupt source itself must go. Set command-register bit 1 (receiver IRQ disable) and leave the transmit control bits at "TX IRQ off". Do not clear command bit 0 - that is DTR, and dropping it makes a Hayes modem hang up (section 3).
  2. Read the status register once afterwards so any already-latched IRQ flag is cleared.
  3. Decide what to do about the character that may be mid-transmission. The stock code decrements $E0A5 for it ($E5CD-$E5D4); the same reasoning applies, since after a disk load the peer will have to resynchronise anyway.
  4. Set uartRestartRequest $E042 = $FF so the connection falls back to phase 0, exactly as stopCommNmi does. The frame layer will then rebuild byte sync, which is the right answer because incoming bytes were dropped on the floor while the drive was busy.
  5. Set $E039 = $80 and $E03A = $80.

On resume ($E039 bit 7 clear and $E03A bit 7 set):

  1. Re-enable the receive interrupt (command bit 1 clear, bit 0 still set).
  2. Throw away whatever the receiver may have latched during the load - read the data register once and clear the overrun/framing/parity bits by reading status.
  3. Clear the module's own bit-level state, set uartRestartRequest $E042 = $01, and re-prime the quiet-line timer carrierSampleTimer $E045 with a negative value, as restartUart does.
  4. Set $E039 = $00 and $E03A = $00.

Do not touch CIA2 at all. The stock configureUserPortLines writes CIA2_CRA = $51 (starts timer A with the serial port in output mode), CIA2_TA_LO/HI, CIA2_SDR, CIA2_DDRB = $26, CIA2_PRB and CIA2_PRA bit 2. The fast loader owns CIA2 port A bits 4-7 and its own DDR; leaving CIA2 alone removes a whole class of interference that the stock driver had to suspend around. The SwiftLink build still has to suspend, because the ACIA's NMI would wreck the loader's cycle timing just as thoroughly.

The NMI chain must be preserved. installCommNmiVector $E57B writes $E685 into $FFFA/$FFFB (the ROMs are banked out, so the vectors are RAM). When the NMI was not the module's, the stock handler restores the interrupt mask and jumps through nmiChainVector $E031/$E032, which the game patches to $1298 (a bare RTI) at $0BF3/$0BFB. $E031/$E032 are inside the frozen block, so the SwiftLink handler must keep chaining through them - that is the path the RESTORE key takes.

2. Carrier detection

Where the bit comes from in the stock driver

serviceCarrierAndSuspendRequest $E5D6, after the suspend handshake:

E5E4  lda $E03B        ; isLinkActive - if the link was never opened, do not sample at all
E5E7  beq $E662        ; (an RTS)
E5E9  lda $E04A        ; carrierOverrideFlags
E5EC  bmi $E604        ; bit 7 would publish A verbatim (no code in the image ever sets it)
E5EE  asl a            ; bit 6 -> bit 7
E5EF  bmi $E5F8        ; the C= + C override is on: report carrier without reading the line
E5F1  eor $DD01        ; CIA2_PRB, through $E04A as a polarity mask (normally $00)
E5F4  and #$10         ; PB4
E5F6  adc #$F0         ; PB4 high -> A=$00 C=1 ; PB4 low -> A=$F0 C=0
E5F8  sec
E5F9  ror a            ; -> $80 no carrier, $F8 carrier, $C0 override
E5FA  inc $E045        ; carrierSampleTimer
E5FD  bpl $E604        ; still positive: a character arrived recently, keep bit 7
E5FF  sta $E045        ; quiet line: park the (negative) sample here so the timer stays negative
E602  and #$7F         ; and strip the "data flowing" bit
E604  sta $E03D        ; linkStatusSample

The user-port bit is CIA2_PRB bit 4 (PB4), user-port pin H, the standard C64 RS-232 DCD input, and carrier means PB4 reads LOW. That inversion is the level converter's doing: on the user port the RS-232 control lines arrive TTL-inverted, so an asserted (positive) DCD at the connector shows up as a 0 at the CIA. With no interface plugged in, PB4 floats/pulls high and the driver reports "no carrier" - which is why the C= + C override exists.

$E04A carrierOverrideFlags doubles as a polarity mask because of the EOR $DD01: setting bit 4 there would invert the sense of the DCD test. No code writes bit 4; only bit 6 is toggled, by the C= + C hot key through $E04B carrierOverrideToggleMask = $40.

The three possible raw samples, before the quiet-line adjustment:

Condition $E03D
PB4 high (no carrier) $80
PB4 low (carrier) $F8
C= + C override active $C0

and with bit 7 stripped when the line has been quiet: $00, $78, $40.

The "data is flowing" bit (bit 7)

carrierSampleTimer $E045 is incremented once per frame here and zeroed by the NMI receiver at $E719 on every character that arrives with a good stop bit. Starting from 0 it takes 128 frames (about 2.1 s NTSC, 2.6 s PAL) to go negative, at which point rearmCarrierTimer $E5FF stores the sample into it - a negative value - so it stays negative until the next good character resets it to 0. Bit 7 of $E03D therefore means "a character has been received in the last ~2 seconds".

The SwiftLink build must reproduce this: zero $E045 from the receive path each time a byte is taken out of the ACIA's data register with no framing/parity error.

Debouncing, and how it reaches $E03C

pollCarrierState $E3CB (the $E00C implementation):

E3CB  jsr $E5D6            ; suspend handshake + fresh sample
E3CE  lda $E03D            ; linkStatusSample
E3D1  cmp $E03C            ; linkStatus
E3D4  beq $E3E3            ; agrees - reset the debounce count
E3D6  inc $E043            ; carrierDebounceCount
E3D9  ldx $E043
E3DC  cpx #$F0             ; 240 consecutive disagreeing samples
E3DE  bcc $E3EA
E3E0  sta $E03C            ; accept the new value
E3E3  ldx #$00
E3E5  stx $E043
E3E8  beq $E3F2
E3EA  and #$80             ; meanwhile take only the "data flowing" bit ...
E3EC  ora $E03C            ; ... and merge it in (this can only SET bit 7, never clear it)
E3EF  sta $E03C
E3F2  rts

240 frames is about 4.0 s NTSC / 4.8 s PAL - deliberately slow, because a modem's DCD twitches during negotiation. It applies to the first acquisition too, so "WAITING FOR CONNECTION..." sits on screen for at least four seconds after carrier appears.

Note the asymmetry at $E3EA: between accepted transitions the merge can only turn bit 7 on. Bit 7 of $E03C therefore only ever falls when a whole new sample is accepted, i.e. after 240 frames. hangUpModemSetState at $1B4B relies on exactly that (see below).

The three bytes the rest of the game reads

Byte Written by Read by
$E03B isLinkActive openCommLink $E2D2 (INC -> 1); cleared by clearLinkVars $E2AE; forced to 0 by the game at $1B01 in a trainer game $0F60, $0F71, $1331, $1352, $1A40, $1A8C, $1B20, $1B38, $1B5F, $4E46, $795D, and $E13E inside the module
$E03C linkStatus pollCarrierState only $1B4B (bit 7), $1BB6 (bit 6), $56B1 (bit 6), $65BC (bit 6); inside the module $E2F8, $E622, $E787, $E7E3
$E03D linkStatusSample serviceCarrierAndSuspendRequest only $1BAB (bit 6); inside the module $E61F

$E03B is not carrier-derived. It only says "$E003 X=0 has been called and the UART has been opened". Do not make it depend on DCD.

Bit meanings, which must not change:

  • $E03C bit 6 = carrier present (debounced). pollMenuSession $56B1 tears the whole session down and jumps to disconnectFromOpponent $1C17 when it is clear; waitForCarrier $1BB6 spins until it is set; the VOICE PAUSE handler $65BC uses it to decide whether to warn the opponent first.
  • $E03C bit 7 = data has been flowing. hangUpModemSetState $1B4B waits (up to $26 ticks of zp_7F) for it to go clear after telling the player to pick up the handset.
  • $E03D bit 6 = the undebounced carrier bit. waitForCarrier $1BAB uses it to step a countdown that nothing ever tests, so only bit 6's value matters, not its timing.

Reading DCD on the 6551 instead

Status register (base+1) bit 6 is the DCD line and it is active low, so:

        lda ACIA_STATUS         ; base+1
        and #$40                ; bit 6: DCD line
        ; Z=1 (bit clear) means carrier present

Two SwiftLink-specific facts, both from CMD's own SwiftLink-232 Application Notes v1.1 (http://csbruce.com/cbm/ftp/reference/swiftlink.txt), sections 3.2 and 4:

  1. DCD and DSR are swapped at the ACIA on the SwiftLink. CMD deliberately exchanged the two signals so the 6551's receiver (which the chip gates on its own /DCD pin) stays enabled at all times while the user still gets to see the modem's DCD. The result is that status bit 6 reflects the modem's DCD line and bit 5 reflects DSR, which is the opposite of the plain 6551 data sheet and of the register summary in this project's task brief. Use bit 6.
  2. An unwired DCD reads as carrier present. There are pull-up resistors on DCD, DSR and CTS, so "if you happen to use a cable that is missing the DCD line, the pull-up resistor will pull the line active, so that bit #6 in the status register would be cleared". For a null-modem cable that carries only TxD, RxD and ground this is exactly the behaviour the game wants: permanent carrier, no need for the C= + C override at all.

Also worth knowing: a state change on DCD or DSR raises an ACIA interrupt in its own right, so the NMI handler must be prepared for an NMI in which neither RDRF nor TDRE is set. Reading the status register clears the interrupt flag.

Caution about dummy reads. On the 6502, STA abs,Y and LDA abs,X perform a read at the un-carried address whenever the index crosses a page. loadBaudParameters $E353 uses sta $DF59,y with Y = $FD..$FF to reach $E056-$E058; the un-carried read lands on $DF56. If the cartridge is strapped to $DF00 and mirrors its four registers through the page, $DF56 aliases the command register ($56 & 3 = 2), which is harmless - but the same trick landing on $DF55 would alias the status register, and reading status clears the pending interrupt flag. Avoid absolute-indexed addressing whose un-carried address can fall inside the ACIA's page.

3. The modem state machine ($E756) and connection phases ($E040)

Call path

Raster IRQ $1129 -> $E000 X=0 -> commRequestDispatch $E111 -> serviceCommTick $E12F. serviceCommTick takes the re-entrancy lock serviceLock $E0A7 (which rests at 1), always calls runModemStateMachine $E756, and only when connectionPhase $E040 >= 3 and $E03B is non-zero does it also run receivePacketFsm $E14D and sendPacketFsm $E1DD.

runModemStateMachine falls through into sendNextModemCommandChar $E7CA and from there into runLinkStateMachine $E7F7, so one call walks the whole chain.

The four phases

$E040 Name What runs Border (see below)
0 idle / link down $E765 sees phase 0 with no restart request and returns immediately black / yellow
1 modem command + terminal mode serviceModemInput $E799 and sendNextModemCommandChar $E7CA shuttle characters between the modem and the two host rings light blue / blue
2 byte sync beginByteSyncPhase $E805 / continueByteSync $E828 light red / red
3 packet protocol runPacketPhase $E858, plus the packet FSMs above light green / green

Phase 0 is entered by stopCommNmi writing $FF into uartRestartRequest $E042, which $E75D-$E762 consumes by storing 0 into $E040. A request of $01 (from restartUart) means "re-evaluate but keep the phase".

How phase 1 is entered and left

E77B  lda #$01
E77D  cmp $E040
E780  beq $E799        ; already in terminal mode
E782  ldx $E03F        ; modemReplyTimer
E785  bne $E791        ; a modem dialogue is under way -> enter/stay in terminal mode
E787  bit $E03C        ; debounced carrier byte
E78A  bvs $E7F7        ; V = bit 6 = carrier: skip the modem dialogue entirely
E78C  jsr $E0C3        ; popHostOutRing - no carrier: discard everything queued for the opponent
E78F  bcc $E78C
E791  jsr $E534        ; flushUartTxRing
E794  lda #$01
E796  sta $E040        ; phase 1

So terminal mode is entered when either an AT command is in flight ($E03F non-zero) or there is no carrier. $E78A is the important branch: with carrier present and no AT string pending, the module jumps straight to runLinkStateMachine and never visits phase 1.

Leaving phase 1 goes through modemReplyTimer $E03F:

  • queueModemCommandString $E393 sets it to $FF (negative = "a command string is going out").
  • sendNextModemCommandChar arms it with $79 = 121 frames (~2.0 s NTSC) when it transmits the CR that terminates an AT command ($E7D5-$E7DE).
  • $E7E3-$E7E9: while $E03C bit 6 is clear the timer is pinned at 1, so it fires on the first tick after carrier appears.
  • When it reaches 0 the module fabricates a CR into the host input ring ($E7F2/$E7F4) so the chat line unblocks, and falls straight into runLinkStateMachine.

runLinkStateMachine $E7F7 then dispatches: any non-zero linkErrorCount $E047 restarts byte sync whatever the phase; phase 2 continues the sync at $E828; phase 3 or above runs the packet protocol; phases 0 and 1 fall through into a fresh beginByteSyncPhase.

Where the Hayes strings live and how they go out

Three canned strings sit in page $EB, stored back to front, because queueModemCommandString $E393 copies them forwards into hostOutRing+1 (modemCommandChars $E092) and then sets the read index to the character count, and popHostOutRing walks that index downwards.

Address Emitted as Queued by
$EBD6 modemHangUpString delay $A0, +, +, +, delay $A0, CR, delay $C0, A, T, H, 0, CR hangUpModem $E390, via hangUpStringPtrLo $EBFD = $D6
$EBE3 modemAnswerInitString delay $C0, ATQ0V1X1A, CR openCommLink $E2E3 when $E011 = 0
$EBF0 modemDialInitString delay $C0, ATQ0V1X1D, CR openCommLink $E2E3 when $E011 = 1

modemInitStringPtrLoTable $EBFE holds the two low bytes $E3,$F0; the high byte $EB is the un-patched half of the self-modifying LDA $EB00,x at $E3A1.

A queued byte with bit 7 set is never transmitted: $E7D0 parks it in modemDelayCounter $EB01 as a negative frame count, which tickGuardDelay $E7C0 counts back up to zero. That is how the Hayes +++ guard times are produced ($A0 = 96 frames, $C0 = 64 frames).

Two things worth noticing about the dial string: it is ATD with no telephone number, and the game's prompts ("GET OPPONENT ON PHONE AND...", "PRESS A OR O AND SET MODEM.", "PRESS SPACE, WAIT, HANGUP PHONE.") make it clear that the players are expected to establish the voice call by hand and then hand the line to the modems. Modem Wars never dials anybody. So a direct-cable build loses nothing by not dialling.

$E048 modemOptionFlags ($CC on disk) gates all of this:

  • bit 7 = "a Hayes modem is attached". queueModemCommandString $E393 tests it first and returns A = $FF without doing anything when it is clear - no AT string is ever queued and $E03F is never set. serviceModemInput $E79E also discards everything received in phase 1 when it is clear.
  • bit 6 = "watch the verbose result codes". checkConnectSpeed $E7A7 sniffs for a CR preceded by '0' (the last digit of CONNECT 1200; a plain CONNECT ends in T and is ignored) and switches to the second baud entry.

$E048 is at $E048, one byte past the end of the frozen block $E01D-$E047, so the SwiftLink build is free to change it.

What a direct null-modem connection has to skip

Set $E048 bit 7 clear (and bit 6 clear - there is no result code to sniff). Then:

  • openCommLink $E2E1-$E2E3 still calls queueModemCommandString, but it returns immediately, so $E03F modemReplyTimer stays 0 and hostOutCount $E02E stays 0.
  • Because $E02E stays 0, the chat editor at $1357 opens straight away instead of waiting for an AT string to drain.
  • On the first tick after openCommLink, $E782 finds $E03F = 0 and $E787 finds carrier, so $E78A branches to runLinkStateMachine and the module goes 0 -> 2 -> 3 without ever entering phase 1. Nothing needs to be cut out of the state machine; it already has the path.
  • hangUpModem $E38D still calls dropDtrLine $E574 and then queueModemCommandString, which again does nothing. On the ACIA the equivalent of dropDtrLine is clearing command bit 0, which also disables the receiver and all interrupts - that is the correct behaviour for "hang up", and commLinkControlDispatch $E2A2-$E2A9 immediately follows it with stopCommNmi. Note $E2A5 spins on modemReplyTimer $E03F until LSR leaves zero, i.e. until it is 0 or 1; with no modem configured $E03F is already 0, so that loop exits at once.
  • Carrier: with no DCD wire the SwiftLink's pull-up reports carrier permanently, so $E03C bit 6 settles set after the 240-frame debounce and stays there. The C= + C override is then redundant, but it costs nothing to leave it working for people whose cable does carry a real DCD.

The one thing a null-modem build genuinely must handle differently is who starts talking first. The byte-sync handshake at $E805 is symmetric (both ends send $00 until they see a $00, then answer $FF and wait for the peer's $FF, restarting after ten fruitless rounds), so answer and originate are interchangeable at that level. $E011 isOriginateMode still matters, though: the game asks the ANSWER/ORIGINATE question at $0CBD and stores the result at $0CDE, and the stock module uses it to pick the AT string ($E2DD), to index userPortIdleTable $E04F (both entries are $26, so it makes no difference) and to index serialShiftPatternTable $E051 together with baudIndex. A SwiftLink build should keep reading $E011 if it wants the two ends to disagree about anything (for example, to pick different bit patterns), but nothing above the UART requires it.

Border-colour indicator (useful while bringing the new UART up)

readKeyAndHandleModemHotkeys $E2F0-$E30E paints VIC_BORDER from linkStatusBorderTable $E05F using index = connectionPhase*2 + carrier. Each byte is colour*2 + always-paint:

Index Byte Colour Painted
0 (phase 0, no carrier) $00 black only while C= is held
1 (phase 0, carrier) $0E yellow only while C= is held
2 (phase 1, no carrier) $1D light blue always
3 (phase 1, carrier) $0D blue always
4 (phase 2, no carrier) $14 light red only while C= is held
5 (phase 2, carrier) $04 red only while C= is held
6 (phase 3, no carrier) $1A light green only while C= is held
7 (phase 3, carrier) $0A green only while C= is held

Hold the Commodore key and the border tells you which phase the link is in. Note the whole hot-key path (and therefore this indicator) is skipped while inputLockoutTimer $0B7D is non-zero, because the raster IRQ only calls pollKeyboardEvent $0E56 at $11E7 when it is 0.

4. Baud selection

The stock table and what reads it

baudParameterTable $E059, two 3-byte entries, indexed by baudIndex $E055 (0 or 3):

E059  $50 $0D $01      ; entry 0: bit period $0D50 = 3408 cycles = 300.1 baud, pacing 1
E05C  $53 $03 $02      ; entry 1: bit period $0353 =  851 cycles = 1201.8 baud, pacing 2

(3408 and 851 cycles against the NTSC 1022727 Hz system clock.)

loadBaudParameters $E353 copies three bytes from baudParameterTable+X into bitPeriodLo $E056, bitPeriodHi $E057 and txPaceReload $E058, using ldy #$FD / sta $DF59,y so one register both counts and indexes ($DF59 + $FD = $E056). It returns A = $FF, which is what the hot-key path needs in order to swallow the key.

Three call sites, all inside the module:

Caller X Why
openCommLink $E2C9 baudIndex $E055 cold start
C= + 3 / C= + 1 hot keys, falling through $E350 0 / 3 player choice
switchTo1200Baud $E7B2 3 (loaded at $E7B0) CONNECT 1200 was seen on the line

$E056/$E057 are consumed by setBitPeriod $E657 (which programs CIA2 timer A or timer B) and by the half-bit start-bit delay at $E6E2; $E058 reloads txPaceCounter $E044 at $E644. All three are private to the UART layer, so the SwiftLink build may redefine them - for example as {control-register byte, spare, spare}, or a wider table with more entries.

The game never writes $E055, $E056, $E057 or $E058, and never reads them. There is no saved baud preference anywhere: baudIndex is reset to its disk value ($00 = 300 baud) every time the module is reloaded from track 34, and clearLinkVars does not touch it because it lies outside $E039-$E047. The only thing the game stores about the link setup is $E011 isOriginateMode, written at $0CDE from the A/O prompt.

$E051 serialShiftPatternTable ($27,$2F,$3F,$37, indexed by isOriginateMode EOR baudIndex) and $E04F userPortIdleTable are user-port artefacts with no SwiftLink equivalent; the shift-register write at $E559 goes to CIA2 SP2 (user-port pin 7), which is not part of the standard RS-232 wiring and whose purpose is not established. Both tables are outside the frozen region and can be reused.

The Commodore-key hot keys already in use

readKeyAndHandleModemHotkeys $E2EC is jump-table entry $E015 and is the game's only keyboard read: pollKeyboardEvent starts at $0E56 with a JSR $E015, and the raster IRQ calls it at $11E7 once a frame. It scans through the hook at $E012, whose operand the game patches to scanKeyboard $0DB7 at $0F4B/$0F50.

Key codes are the game's own: from keyMatrixCodeTable $0D67, an ordinary key returns its ASCII code with bit 7 set ('A' = $C1, '3' = $B3), the four function keys return $00-$03, control keys return their ASCII control code with bit 7 set (RETURN = $8D), and $FF means no key. X returns $80 when the Commodore key is held.

Gates before any hot key is considered:

  • $E310 BPL - codes below $80 (the function keys) are handed straight to the game and can never be hot keys.
  • $E312-$E319 - a code equal to lastHotkeyCode $E046 with the Commodore key still down is swallowed, which is the auto-repeat filter.
  • $E31F - the Commodore key must be held (X = $80).

The complete list, in the order the code tests them:

Code Key Address Action
$C8 C= + H $E323 EOR dtrToggleMask $E04C = $24 into CIA2_PRB: toggle PB2 (DTR) and PB5
$C3 C= + C $E332 EOR $E04B = $40 into carrierOverrideFlags $E04A: pretend carrier is present
$B3 C= + 3 $E342 baudIndex $E055 = 0, then loadBaudParameters with X=0: 300 baud
$B1 C= + 1 $E34A baudIndex $E055 = 3, then loadBaudParameters with X=3: 1200 baud
$8D C= + RETURN $E362 DEC skipModemInitString $E041 (0 -> $FF), openCommLink $E2C1, modemReplyTimer $E03F = $8D; returns the RETURN code to the game with X cleared, so this key is not swallowed
$D0 C= + P $E374 hangUpModem $E38D (drop DTR, queue +++/ATH0); its tail returns $FF
$C1 C= + A $E37A isOriginateMode $E011 = 0, then configureUserPortLines $E540
$CF C= + O $E37E isOriginateMode $E011 = 1, then configureUserPortLines $E540

Every one of them except C= + RETURN returns A = $FF so the game never sees the keystroke. So does every other Commodore-key combination: the fall-through at $E38A returns $FF unconditionally. Adding new hot keys therefore cannot break anything the game currently does with the keyboard - those codes are already being eaten - as long as the eight codes above keep their meanings.

Free and sensible for new baud keys: C= + 2 ($B2), C= + 4 ($B4), C= + 9 ($B9), C= + 0 ($B0), C= + 6 ($B6), C= + 8 ($B8). Note that C= + 3 and C= + 1 are already spent on 300 and 1200, so a "digit = speed" scheme has to work around them; mapping C= + 2 -> 2400, C= + 4 -> 4800, C= + 9 -> 9600, C= + 0 -> 19200 and C= + 8 -> 38400 collides with nothing. Avoid $FE and $FF (the modifier and no-key sentinels) and remember that $B3/$B1 must keep working if the module is to stay usable with a real 300/1200 modem.

Conclusion: the doubling claim is correct, and the specific values in the task brief are correct. Verified against two independent sources beyond the brief:

  1. CMD, SwiftLink-232 Application Notes v1.1, section 3.4 and the control-register figure (http://csbruce.com/cbm/ftp/reference/swiftlink.txt). "Note that our cartridge uses a double-speed crystal, so values given on the data sheet are doubled ... (the minimum speed is 100 bps and the maximum speed is 38,400 bps)", followed by the full table below.
  2. The cc65 SwiftLink driver libsrc/c64/ser/c64-swlink.s (https://github.com/cc65/cc65/blob/master/libsrc/c64/ser/c64-swlink.s), whose BaudTable maps 300 -> $05, 600 -> $06, 1200 -> $07, 2400 -> $08, 3600 -> $09, 4800 -> $0A, 7200 -> $0B, 9600 -> $0C, 19200 -> $0E, 38400 -> $0F, and marks 50/75/110/134.5/1800 as unavailable - exactly what a doubled table predicts (1800 is unreachable because the nominal 1800 entry now yields 3600).

Control register (base+3) = $10 (bit 4 = internal baud-rate generator, bits 6-5 = 00 = 8 data bits, bit 7 = 0 = one stop bit) OR the rate bits:

Bits 3-0 Control byte SwiftLink rate Nominal 6551 rate
$00 $10 16x external clock 16x external clock
$01 $11 100 50
$02 $12 150 75
$03 $13 219.84 109.92
$04 $14 269.16 134.58
$05 $15 300 150
$06 $16 600 300
$07 $17 1200 600
$08 $18 2400 1200
$09 $19 3600 1800
$0A $1A 4800 2400
$0B $1B 7200 3600
$0C $1C 9600 4800
$0D $1D 14400 7200
$0E $1E 19200 9600
$0F $1F 38400 19200

To stay wire compatible with the stock driver, the SwiftLink build must be able to produce 300 ($15) and 1200 ($17). Those are the only two speeds the stock module can talk at, so a stock machine and a SwiftLink machine can only meet there.

Bear in mind that the game's throughput ceiling is not the line speed. The frame layer starts at most one thing per raster IRQ ($E860 refuses when uartPendingCount >= 2) and the packet layer exchanges one packet per lock-step turn, so past a few thousand baud the win is latency and retransmit cost, not bandwidth.

5. $E030 - the build id

commBuildId $E030 is one byte inside the frozen block $E01D-$E047.

Module Value Source
game/modemDriverE000 (track 18 s7 + track 34) $00 modemDriverE000.s $E030, and byte $030 of the rebuilt 4096-byte image
game/trainerAiE000 (track 35) $FF trainerAiE000.s $E030

Only bit 7 is ever tested. Nothing in the module writes it; it is a constant baked into the sector image, and it survives every clearInlineVarBlock because none of the cleared blocks covers $E030 (clearPacketVars stops at $E02F, clearLinkVars starts at $E039).

The five read sites in mainProgram0800.s:

Address Routine What the game does
$0AEA startGameFromSetup EOR isSoloTrainer ($0BA5), AND #$80: if bit 7 of the wanted opponent and the resident module disagree, $0AF1/$0AF4 load the other module off disk
$0B2C startGameFromSetup LDA $E030 / STA $0BA5: the resident module's id is written back over the wanted flag, so $0BA5 afterwards always describes what is actually in memory
$1A9F miscTabAction (ABORT GAME) negative: skip the $8C/reason-0 "I am leaving" handshake, because the trainer discards everything sent to it
$1AFA endGameToMainMenu negative: force isLinkActive $E03B to 0 and preselect main-menu item 1 (PRACTICE WITH SOLO TRAINER) instead of item 0
$1B3D hangUpModemSetState negative: skip the "PICK UP PHONE THEN PRESS SPACE." prompt (message $17) and the carrier-quiet wait at $1B4B, and go straight to $E003 X=3

The SwiftLink module must keep $E030 = $00. It is the modem build; a $FF there would make the game believe the solo trainer is resident, which would (a) make $0AEA reload track 34 over the top of the SwiftLink module every time a modem game starts, (b) suppress the hang-up handshake and the end-of-game link teardown, and (c) rewrite $0BA5 to $FF, turning the game into a solo game. checkAbi.py enforces this as part of the $E01D-$E047 frozen region, but it is worth stating explicitly because it is the one byte in that region that carries meaning rather than state.

Two related bytes in the same frozen block, for completeness:

  • $E02F peerCommBuildId - the opponent's $E030, stored by exchangePlayerNamesAndSettings at $EDAD during the start-of-session handshake. Both ends of a modem game therefore report $00 to each other; a SwiftLink machine and a stock machine will exchange identical ids, which is what we want since the wire protocol is unchanged.
  • $E045-$E047 hold $44 $54 $48 ("DTH") on disk - leftovers from the sector image, because this page comes from track 18 sector 7, outside the encrypted area. They are inside the frozen region, so the SwiftLink image must reproduce those exact junk bytes even though clearLinkVars $E034 zeroes them the moment the link is closed.

6. Checklist for the replacement UART layer

Contracts, in the order they will bite:

  1. $E030 stays $00; $E01D-$E047 and $E000-$E017 come out byte-identical, junk bytes included.
  2. $E039/$E03A suspend handshake served once per frame from the $E00C path, with $80/$00 bit-7 semantics; ACIA interrupts genuinely off between them; CIA2 never touched.
  3. $E031/$E032 still chained on a foreign NMI.
  4. $E03D publishes $80/$F8-shaped samples (bit 6 = carrier, bit 7 = data flowing within ~128 frames), driven from ACIA status bit 6 with carrier = bit clear; $E045 zeroed from the receive path; the 240-frame debounce at $E3CB left exactly as it is.
  5. $E03B set by openCommLink, not by carrier.
  6. $E0A5 uartPendingCount accurate at all times; $E044 writable by $E820.
  7. queueByteForTransmit $E73C, flushUartTxRing $E534, configureUserPortLines $E540, dropDtrLine $E574, installCommNmiVector $E57B, restartUart $E586, stopCommNmi $E5C3 and serviceCarrierAndSuspendRequest $E5D6 keep their addresses.
  8. $E048 bit 7 clear for a cable-only build (no AT strings, no result-code sniffing, phase 1 never entered); leave the option so a real modem can still be driven.
  9. loadBaudParameters $E353 keeps its three-byte stride and its A = $FF return, or all four of its call sites ($E2C9, $E350 fall-through, $E7B2) change together.
  10. New hot keys only in the free codes; $C8 $C3 $B3 $B1 $8D $D0 $C1 $CF keep their present meanings, and C= + RETURN keeps returning the key to the game rather than swallowing it.

7. Open questions

  • Which SwiftLink clones swap DCD and DSR? CMD's own cartridge does (section 2), and the Turbo232 is documented as SwiftLink compatible, but the modern reimplementations (GLINK232 and friends) advertise "modem control" as an option and do not all say which way round they wire it. A build that only ever sees a null-modem cable does not care, because both bits read active; a build that wants real DCD should probably offer a bit in $E04A to select which status bit to look at, since $E04A is already used as a polarity/override mask by $E5F1.
  • Base address. $DE00 is the default, $DF00 the strap option; CMD's own notes recommend that software cope with both (probe by writing the control register and reading it back). A run-time probe costs a few bytes and removes a support headache, and there is plenty of room in the freed UART region.
  • Status-register bit 7 at high speed. CMD warns that bit 7 (interrupt occurred) is unreliable at 9600 bps and above, and their own sample NMI handler ignores it and tests RDRF/TDRE/DCD/DSR directly. Worth following.
  • CTS. The 6551 stops transmitting on its own when CTS goes inactive, and clears TDRE while it does, with no way to see CTS from any register. On a null-modem cable with RTS/CTS crossed, that gives a free flow-control mechanism during disk loads: raising RTS on the suspending machine (both transmit-control bits clear) throttles the peer's transmitter automatically. Whether that is worth using, or whether it is better to let the ARQ layer clean up as it does today, is a design decision the driver work will have to make.