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/$E3BCand the table at$E3BF; - the frame/ARQ layer
$E909-$EAB2and 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:
$E76BserviceLinkTickcompares it withframeInFlightFlag$E0A4to decide that a queued packet has drained and a new one may be built;$E82DcontinueByteSyncwaits for it to reach 0 before sending the next$00sync byte;$E860servicePacketTransmitrefuses to start anything new while it is 2 or more;$E539flushUartTxRingresets it fromtxCharActive$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.
What the SwiftLink UART layer must do
On suspend ($E039 bit 7 set and $E03A bit 7 clear):
- 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). - Read the status register once afterwards so any already-latched IRQ flag is cleared.
- Decide what to do about the character that may be mid-transmission. The stock code decrements
$E0A5for it ($E5CD-$E5D4); the same reasoning applies, since after a disk load the peer will have to resynchronise anyway. - Set
uartRestartRequest$E042 = $FFso the connection falls back to phase 0, exactly asstopCommNmidoes. 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. - Set
$E039 = $80and$E03A = $80.
On resume ($E039 bit 7 clear and $E03A bit 7 set):
- Re-enable the receive interrupt (command bit 1 clear, bit 0 still set).
- 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.
- Clear the module's own bit-level state, set
uartRestartRequest$E042 = $01, and re-prime the quiet-line timercarrierSampleTimer$E045with a negative value, asrestartUartdoes. - Set
$E039 = $00and$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:
$E03Cbit 6 = carrier present (debounced).pollMenuSession$56B1tears the whole session down and jumps todisconnectFromOpponent$1C17when it is clear;waitForCarrier$1BB6spins until it is set; the VOICE PAUSE handler$65BCuses it to decide whether to warn the opponent first.$E03Cbit 7 = data has been flowing.hangUpModemSetState$1B4Bwaits (up to$26ticks ofzp_7F) for it to go clear after telling the player to pick up the handset.$E03Dbit 6 = the undebounced carrier bit.waitForCarrier$1BABuses 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:
- 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.
- 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$E393sets it to$FF(negative = "a command string is going out").sendNextModemCommandChararms it with$79= 121 frames (~2.0 s NTSC) when it transmits the CR that terminates an AT command ($E7D5-$E7DE).$E7E3-$E7E9: while$E03Cbit 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 intorunLinkStateMachine.
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$E393tests it first and returnsA = $FFwithout doing anything when it is clear - no AT string is ever queued and$E03Fis never set.serviceModemInput$E79Ealso discards everything received in phase 1 when it is clear. - bit 6 = "watch the verbose result codes".
checkConnectSpeed$E7A7sniffs for a CR preceded by'0'(the last digit ofCONNECT 1200; a plainCONNECTends inTand 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-$E2E3still callsqueueModemCommandString, but it returns immediately, so$E03FmodemReplyTimerstays 0 andhostOutCount$E02Estays 0.- Because
$E02Estays 0, the chat editor at$1357opens straight away instead of waiting for an AT string to drain. - On the first tick after
openCommLink,$E782finds$E03F= 0 and$E787finds carrier, so$E78Abranches torunLinkStateMachineand 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$E38Dstill callsdropDtrLine$E574and thenqueueModemCommandString, which again does nothing. On the ACIA the equivalent ofdropDtrLineis clearing command bit 0, which also disables the receiver and all interrupts - that is the correct behaviour for "hang up", andcommLinkControlDispatch$E2A2-$E2A9immediately follows it withstopCommNmi. Note$E2A5spins onmodemReplyTimer$E03FuntilLSRleaves zero, i.e. until it is 0 or 1; with no modem configured$E03Fis already 0, so that loop exits at once.- Carrier: with no DCD wire the SwiftLink's pull-up reports carrier permanently, so
$E03Cbit 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:
$E310BPL- codes below$80(the function keys) are handed straight to the game and can never be hot keys.$E312-$E319- a code equal tolastHotkeyCode$E046with 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.
The SwiftLink baud table, and verification of the doubling claim
Conclusion: the doubling claim is correct, and the specific values in the task brief are correct. Verified against two independent sources beyond the brief:
- 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.
- The cc65 SwiftLink driver
libsrc/c64/ser/c64-swlink.s(https://github.com/cc65/cc65/blob/master/libsrc/c64/ser/c64-swlink.s), whoseBaudTablemaps 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:
$E02FpeerCommBuildId- the opponent's$E030, stored byexchangePlayerNamesAndSettingsat$EDADduring the start-of-session handshake. Both ends of a modem game therefore report$00to 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-$E047hold$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 thoughclearLinkVars$E034zeroes them the moment the link is closed.
6. Checklist for the replacement UART layer
Contracts, in the order they will bite:
$E030stays$00;$E01D-$E047and$E000-$E017come out byte-identical, junk bytes included.$E039/$E03Asuspend handshake served once per frame from the$E00Cpath, with$80/$00bit-7 semantics; ACIA interrupts genuinely off between them; CIA2 never touched.$E031/$E032still chained on a foreign NMI.$E03Dpublishes$80/$F8-shaped samples (bit 6 = carrier, bit 7 = data flowing within ~128 frames), driven from ACIA status bit 6 with carrier = bit clear;$E045zeroed from the receive path; the 240-frame debounce at$E3CBleft exactly as it is.$E03Bset byopenCommLink, not by carrier.$E0A5uartPendingCountaccurate at all times;$E044writable by$E820.queueByteForTransmit$E73C,flushUartTxRing$E534,configureUserPortLines$E540,dropDtrLine$E574,installCommNmiVector$E57B,restartUart$E586,stopCommNmi$E5C3andserviceCarrierAndSuspendRequest$E5D6keep their addresses.$E048bit 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.loadBaudParameters$E353keeps its three-byte stride and itsA = $FFreturn, or all four of its call sites ($E2C9,$E350fall-through,$E7B2) change together.- New hot keys only in the free codes;
$C8 $C3 $B3 $B1 $8D $D0 $C1 $CFkeep 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
$E04Ato select which status bit to look at, since$E04Ais already used as a polarity/override mask by$E5F1. - Base address.
$DE00is the default,$DF00the 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.