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

44 KiB

spec_uart.md - the hardware contract of the stock Modem Wars UART layer

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

What this file is: the exact, verified behaviour of every routine in disassembly/game/modemDriverE000.s that touches the C64's hardware, written so the same routines can be rebuilt against a 6551 ACIA on a SwiftLink cartridge without any code above them noticing.

Every address, instruction boundary and caller below was checked against the listing disassembly/game/modemDriverE000.s and disassembly/XREF.txt. Where the shipped comments in the listing are wrong, this file says so and gives the correct behaviour.

0. Conventions

  • "Range" gives the first byte of the routine and, after the dash, the address of the byte after its last - so $E534-$E540 means the routine occupies $E534..$E53F inclusive and the next routine starts at $E540. A replacement must fit in exactly that span or leave the tail padded.
  • "Callers" lists the address of the JSR/JMP/branch, not the routine name only, so each can be checked.
  • Flag names are the 6502's: N Z C V.
  • "the frame tick" means the raster IRQ at $1129/$112C, which calls jump-table entry $E000 with X=0 and then entry $E00C, in that order, once per frame.

1. Where the seam actually is

Everything above the UART layer reaches the hardware through a very small number of doors. These are the complete set; if the replacement honours all of them, nothing above changes:

Door Direction Used by
queueByteForTransmit $E73C byte out $E7D2, $E81D, $E87C, $E92A
popUartRxRing $E4D2 (via thunk $E739) byte in $E743, $E799, $E80A, $E9E9
flushUartTxRing $E534 discard queued output $E791, $E80F
uartPendingCount $E0A5 "how many bytes do I still owe the wire" $E1EC, $E26E, $E628, $E76B, $E82D, $E860
linkStatus $E03C / linkStatusSample $E03D carrier + activity $E622, $E787, $E7E3, and the game at $1B4B, $1BAB, $1BB6, $56B1, $65BC
reportLinkError $E3BC -> countLinkError $E403 "that character was bad" NMI only ($E724)
nmiSuspendRequest/nmiSuspendAck $E039/$E03A disk-load handshake game at $0F67/$0F6A and $0F78/$0F7B
ciaIcrShadow $E033 mask the game restores after banking I/O out game at $588C, $58AB, $58D0
$E003 X=0/1/2/3 and the hot keys open, init, stop, hang up, baud, DTR $E29A dispatcher

No code outside the module references anything in $E048-$EBFF of the modem driver. (A scan of XREF.txt for external references into that window returns only trainerSetupEC00 and droneAiEE00 entries, which belong to the trainer overlay that shares those addresses, not to this module.) So the whole UART layer is free to be rewritten as long as the doors above keep their addresses and semantics, and the three frozen regions - $E000-$E017, $E01D-$E047, $EC00-$EFFF - come out byte-identical.

Note that the frozen constraint on $E01D-$E047 is on the disk image bytes, not on runtime meaning: $E039-$E047 is zeroed by clearLinkVars at run time anyway, and $E045/$E046/$E047 ship as the leftover ASCII D T H from the sector image. A replacement may give those bytes new runtime meanings; it may not change what is stored in the file.

2. Hardware the stock UART layer owns

Every CIA2 access in the whole module, with the address of the instruction:

Register Read at Written at Role
CIA2_PRA $DD00 $E56D, $E66E, $E6BC $E570, $E6C5 PA2 = TXD. Always read-modify-write, because PA0/PA1 are the VIC bank select
CIA2_PRB $DD01 $E32A, $E5F1, $E6F1 $E32D, $E568, $E577 PB0 = RXD, PB1 = RTS, PB2 = DTR, PB4 = DCD, PB5 = spare output
CIA2_DDRB $DD03 - $E55F $26: PB1, PB2, PB5 out
CIA2_TA_LO/HI $DD04/$DD05 - $E547/$E542, $E65D/$E657 (X=0) receive bit clock latch
CIA2_TB_LO/HI $DD06/$DD07 $E68D, $E695 (hi) $E65D/$E657 (X=2) transmit bit clock latch
CIA2_SDR $DD0C - $E559 one fixed pattern shifted out of SP2 (user-port pin 7); purpose unknown
CIA2_ICR $DD0D $E596, $E690, $E69C $E59B, $E67C, $E68A, $E6CA, $E702 NMI source mask / flags
CIA2_CRA $DD0E - $E54C, $E59E, $E6E9, $E729 timer A control
CIA2_CRB $DD0F $E607 $E651, $E5A1, $E6AD timer B control

Nothing in the module touches CIA2_DDRA; PA2 is already an output because the boot loader and fast loader set DDRA. A 6551 driver should touch none of the above except CIA2_ICR, and there only to keep it disabled (see section 5).

3. Routine contracts

$E353 loadBaudParameters - range $E353-$E362

Copies the three-byte baud entry baudParameterTable+X into the live bit-period and pacing bytes.

  • In: X = 0 (300 baud) or 3 (1200 baud). Y is loaded internally.
  • Reads: $E059+X, $E059+X+1, $E059+X+2.
  • Writes: $E056 bitPeriodLo, $E057 bitPeriodHi, $E058 txPaceReload.
  • Out: A = $FF (N=1, Z=0), X = X_in+3, Y = $00, C unchanged, memory as above. The A = $FF return value is load bearing: the C=+3 / C=+1 hot keys fall into this routine and use its return as the "key swallowed" answer to the game.
  • Callers: JSR $E2C9 (openCommLink), JSR $E7B2 (switchTo1200Baud, on a CONNECT 1200 result code); fall-through from $E350 (STX baudIndex) on the C=+3 and C=+1 hot keys. XREF.txt lists only the two JSRs because the third path is a fall-through.
  • Trap for a 6551 build: the loop stores with STA $DF59,Y ($E358), abusing $DF59+$FD = $E056 so one register both counts and indexes. STA abs,Y always performs a dummy read at (base AND $FF00) OR ((baseLo+Y) AND $FF), i.e. at $DF56, $DF57 and $DF58. On a SwiftLink strapped to $DF00, $DF58 AND 3 = 0 is the data register: that dummy read silently consumes a received byte and clears RDRF. This is the only instruction in the entire game that names an address in the $DE00/$DF00 I/O windows, so rewriting these three stores as plain absolute STAs removes the whole class of problem.

$E534 flushUartTxRing - range $E534-$E540

Throws away everything queued for transmission and resynchronises the pending count with reality.

  • In: the UART transmit ring, txCharActive $E5BB.
  • Reads: uartTxCount $E414, uartTxReadIndex $E415, uartTxRing $E417..$E42C, $E5BB.
  • Writes: $E414 to 0, $E415 walked down to match, uartPendingCount $E0A5 = $E5BB (1 if a character is still being shifted out, 0 if not).
  • Out: A = the value written to $E0A5, X = clobbered, C = 1 (left set by the final empty popUartTxRing), N/Z from the LDA txCharActive.
  • Callers: JSR $E791 (enterTerminalMode), JSR $E80F (restartByteSync); self-loop branch at $E537.
  • 6551 note: the "1 if a character is still shifting" rule exists so the packet layer's $E0A5-reaches-zero test stays honest across a flush. With a 6551 the equivalent is "1 if a byte has been written to the data register whose TDRE has not come back yet, else 0".

$E540 configureUserPortLines - range $E540-$E574

Programs CIA2 for the user-port serial link and asserts the modem control lines.

  • In: isOriginateMode $E011, baudIndex $E055, userPortDdrbValue $E04D ($26), userPortIdleTable $E04F (both entries $26), serialShiftPatternTable $E051 ($27 $2F $3F $37).
  • Writes, in order: CIA2_TA_HI = 0, CIA2_TA_LO = 3 (latch $0003), CIA2_CRA = $51 (timer A continuous, serial port in output mode), CIA2_SDR = pattern selected by (3 AND isOriginateMode) EOR baudIndex, CIA2_DDRB = $26, CIA2_PRB = $26 (RTS, DTR, PB5 all asserted), CIA2_PRA |= $04 (TXD to the idle mark level, read-modify-write so the VIC bank bits survive).
  • Out: A = the value written to CIA2_PRA, X = isOriginateMode, Y unchanged, N/Z from the ORA CIA2_PRA, C unchanged.
  • Callers: JSR $E2D5 (openCommLink, on every open including a re-open), JSR $E387 (the C=+A / C=+O answer/originate hot keys).
  • Its tail $E56B-$E573 is a separate entry: setCiaNmiMask ends with JMP $E56B. Any replacement must keep a valid three-instruction "raise TXD / RTS" tail at $E56B or repoint that JMP.
  • 6551 equivalent: program the control register ($10 OR rateBits, 8N1, internal clock) and the command register ($09 = DTR on, receiver IRQ enabled, RTS low, no transmit IRQ; or $0D when the transmit interrupt is wanted). The CIA2_SDR write, the timer A latch and the CIA2_PRA bit are all dead.

$E574 dropDtrLine - range $E574-$E57B

  • In: userPortDtrDropped $E04E = $22.
  • Writes: CIA2_PRB = $22 - PB2 (DTR) low, PB1 (RTS) and PB5 still high. Dropping DTR is what makes a Hayes modem let go of the line.
  • Out: A = $22 (N=0, Z=0), X/Y/C unchanged.
  • Callers: JSR $E38D (hangUpModem, itself reached from $E2A2 for $E003 X>=3 and from the C=+P hot key at $E376).
  • 6551 equivalent: write the command register with bit 0 = 0. Note that on the 6551 clearing bit 0 also disables the receiver and all ACIA interrupts, which is the desired behaviour here.

$E57B installCommNmiVector - range $E57B-$E586

  • In: none.
  • Writes: $FFFA = $85, $FFFB = $E6 - i.e. the RAM NMI vector points at commNmiHandler $E685. The KERNAL ROM is banked out ($01 = $35), so $FFFA/$FFFB really is RAM.
  • Out: A = $85, X = $E6, N=1 Z=0 (from LDX #$E6), C unchanged.
  • Callers: JSR $E2CC (openCommLink, only on a cold open when isLinkActive was 0).
  • 6551 note: the constants $85/$E6 are the only thing that has to change if the handler moves, but it must not move: nothing else does, and keeping it at $E685 keeps this routine two bytes.

$E586 restartUart - range $E586-$E593 (falls into setCiaNmiMask)

(Re)starts the software UART.

  • In: none.
  • Calls: clearUartState $E5B6, which zeroes $E5BB-$E5C0 and returns here normally.
  • Writes: $E5BB-$E5C0 = 0; uartRestartRequest $E042 = $01 ("re-evaluate the connection", as opposed to $FF which forces phase 0); carrierSampleTimer $E045 = $92.
  • Falls through into setCiaNmiMask with A = $92, which arms the CIA2 FLAG line (the RXD start-bit edge) and timer B, and as a side effect writes $00 to both nmiSuspendRequest $E039 and nmiSuspendAck $E03A.
  • Out: as setCiaNmiMask - A = the CIA2_PRA value, X = 0.
  • Callers: JSR $E2CF (openCommLink), JSR $E5E1 (serviceCarrierAndSuspendRequest, the resume half of the disk-load handshake).
  • Load-bearing side effects for the layer above: $E042 = 1 and $E039/$E03A = 0. A 6551 build must still produce exactly those, because runModemStateMachine $E758 consumes $E042 and the game's resumeCommModule $0F7B spins until $E03A bit 7 is clear.

$E593 setCiaNmiMask - range $E593-$E5B6

The single point where the module's interrupt sources are armed or disarmed.

  • In: A = a CIA2 ICR command byte. Only three values are ever passed: $92 (set FLAG + timer B), $83 (set timer A + timer B - written by the NMI into the shadow, not through here) and $7F (clear every source).
  • Reads: CIA2_ICR (the BIT at $E596 clears any pending flags), ciaIcrShadow $E033.
  • Writes: ciaIcrShadow $E033 = A; CIA2_ICR = A; CIA2_CRA = 0 and CIA2_CRB = 0 (stopping both timers); then nmiSuspendRequest $E039 and nmiSuspendAck $E03A both set to (A EOR $FF) AND $80 - which is $00 for $92/$83 and $80 for $7F.
  • Retry loop: CMP ciaIcrShadow / BCC $E593. If the NMI overwrote the shadow with a larger mask while we were storing ($83 or $92 over a $7F), the whole routine repeats. This is a real race, not decoration.
  • Out: exits through JMP $E56B, so A = the value written to CIA2_PRA, X = 0, C set by the final AND #$80, N/Z from the ORA CIA2_PRA.
  • Callers: JSR $E5CA (stopCommNmi), fall-through from restartUart $E590, self-branch $E5A7.
  • 6551 equivalent: "enable" writes the command register with the receive interrupt on; "disable" writes it with bit 1 set (receiver IRQ disabled) and bits 3-2 = 00. The $E039/$E03A and $E033 writes must be kept exactly as they are - see section 5 for why $E033 matters even to a 6551 build.

$E5B6 clearUartState - range $E5B6-$E5C1 (code $E5B6-$E5BA, data $E5BB-$E5C0)

LDX #$06 / JSR clearInlineVarBlock, followed by the six-byte block it clears. clearInlineVarBlock eats its own return address and plants it in a self-modified STA abs,X, so the RTS returns to clearUartState's caller: from the caller's point of view this behaves as an ordinary subroutine.

  • In: none.
  • Writes: $E5BB-$E5C0 = 0.
  • Out: A = 0, X = 0, Z=1 N=0, C unchanged; control returns to restartUart.
  • Callers: JSR $E586 (restartUart) only.
  • 6551 note: the six bytes are pure bit-bang state (section 4). A 6551 build still wants a routine here that zeroes whatever it keeps in that space, because restartUart calls it and flushUartTxRing reads $E5BB.

$E5C1 suspendUartIfRunning - range $E5C1-$E5C3

Two bytes: BCS $E5D5. It sits between the UART state block and stopCommNmi so the suspend path can fall into the stop path.

  • In: C = bit 7 of nmiSuspendAck, shifted out by the ASL at $E5D9.
  • Out: if C=1 (already suspended) it branches to the RTS at $E5D5 inside stopCommNmi and nothing happens; if C=0 it falls into stopCommNmi.
  • Callers: branch BMI $E5DD in serviceCarrierAndSuspendRequest. Never called with JSR.

$E5C3 stopCommNmi - range $E5C3-$E5D6

Tears the UART down.

  • In: txCharActive $E5BB.
  • Writes: uartRestartRequest $E042 = $FF (forces connectionPhase back to 0 when the state machine next runs); calls setCiaNmiMask with $7F, so CIA2_ICR is cleared, both timers stopped, $E033 = $7F and $E039 = $E03A = $80; then, if a character was half-way out, DEC uartPendingCount $E0A5 because that character will never finish.
  • Out: A = 0 or $E5BB's value, X = 0, RTS at $E5D5.
  • Callers: JSR $E2AB (commLinkControlDispatch, reached for $E003 X=2 and, after the hang-up string has drained, X>=3), fall-through from suspendUartIfRunning.
  • Ordering that matters: $E042 = $FF is written before the mask is cleared, and the uartPendingCount fix-up happens after. A 6551 build must keep all three effects, because the disk-load suspend goes through this same path and the game spins on $E03A.

$E607 startNextTxChar - body $E607-$E654, exits at the RTS $E662

Starts shifting the next character out. It is reached two ways: from the NMI (JSR $E673) and by fall-through from $E604, the tail of the carrier sampler - so it is retried once per frame from jump-table entry $E00C as well as from the NMI. That frame-rate retry is not optional: when the transmitter goes idle, timer B stops, no more transmit NMIs arrive, and the frame tick is the only thing that can start the machine again.

Guards, in order:

  1. LDA CIA2_CRB / ORA txCharActive / LSR - give up if timer B's START bit is set or a character is already in flight.
  2. If connectionPhase $E040 == 1 (modem command / terminal mode), skip guards 3 and 4 entirely: terminal-mode characters go out unpaced and without carrier.
  3. DEC txPaceCounter $E044; give up unless it reached 0. On the frame it reaches 0 it is reloaded with 1 so the attempt repeats next frame.
  4. LDA linkStatusSample $E03D / AND linkStatus $E03C / ASL - give up unless bit 6 (carrier) is set in both. The module refuses to transmit into a dead line.
  5. LDA uartPendingCount $E0A5 - give up if zero.
  6. JSR popUartTxRing; if the ring was empty although the count said otherwise, force $E0A5 = 0 and give up (this is the self-healing path for a lost decrement).

Then: INC txCharActive, STA txShiftRegister, txBitCountReload = 9, txPaceCounter = txPaceReload $E058, program timer B with a deliberately short first interval (A = $01 as the high byte, bitPeriodLo as the low byte) and CIA2_CRB = $11 (force load + start, continuous), then fall through into setBitPeriodFull so every following underflow is a whole bit time. The RTS at $E662 is the common exit for all six give-up paths and for the success path.

  • Out: A = $11 on the success path, A/flags meaningless on the give-up paths (every one of them branches to $E662). X = 2 on success.
  • Callers: JSR $E673 (nmiStartNextChar), fall-through from $E606.
  • 6551 equivalent: most of this evaporates. What must survive is the policy: do not transmit unless $E0A5 is non-zero and, outside phase 1, only with carrier. Guard 4 is the reason a null-modem player needs the C=+C carrier override ($E04A bit 6) - or, with a 6551, a real DCD from the other end's DTR. Guard 3 (frame pacing) becomes pointless at 9600+ and can be reduced to a no-op, but the byte $E044 must still tolerate beginByteSyncPhase forcing bit 1 into it at $E820-$E825.

$E654 setBitPeriodFull - range $E654-$E657

Three bytes: LDA bitPeriodHi $E057, then falls into setBitPeriod.

  • In: X = 0 (timer A) or 2 (timer B).
  • Callers: JSR $E6EC (the NMI, immediately after the half-bit start-bit delay), fall-through from startNextTxChar $E653.

$E657 setBitPeriod - range $E657-$E663

  • In: A = high byte of the interval, X = 0 or 2, bitPeriodLo $E056.
  • Writes: CIA2_TA_HI,X then CIA2_TA_LO,X, i.e. $DD05/$DD04 for X=0 and $DD07/$DD06 for X=2. The low byte is always the full bitPeriodLo; only the high byte varies (the caller passes $01 for the short first transmit interval and bitPeriodHi >> 1 for the half-bit start-bit delay).
  • Out: A = $11 - the "force load + start" byte the caller stores into CIA2_CRA or CIA2_CRB. X unchanged, N=0 Z=0.
  • Callers: JSR $E64E (startNextTxChar), JSR $E6E6 (the NMI's start-bit path), fall-through from setBitPeriodFull.
  • 6551 note: entirely dead. Baud is a control-register write, once, at open time.

$E663 nmiStartNextChar - range $E663-$E679

The timer B path of the NMI once txBitCounter has gone negative.

  • In: txCharActive $E5BB, txBitCountReload $E5BC, CIA2_PRA.
  • If a character is active: reload txBitCounter $E5BD from $E5BC (9), then read CIA2_PRA and branch to $E6C3 to clear bit 2 - the start bit. The BNE $E671 is an unconditional branch in practice because CIA2_PRA can never read 0 (the VIC bank bits are in it).
  • If not: JSR startNextTxChar, then CLC / BCC $E6C8 into the receive half.
  • Out: falls into the shared NMI tail; nothing returns to a caller.
  • Callers: branch BMI $E6A7 in the NMI only.

$E685 commNmiHandler - range $E685-$E733, plus the "not mine" exit at $E679-$E685

The module's NMI handler. See section 5 for the dispatch and chaining rules, which the replacement must copy exactly. Structure:

Address What happens
$E685-$E687 PHA / TXA / PHA - A then X saved. Y is never saved and never used.
$E688-$E68C CIA2_ICR = $7F - mask every CIA2 source while we work
$E68D-$E68F sample CIA2_TB_HI
$E690-$E692 read CIA2_ICR (which also clears its flags)
$E693-$E694 BPL $E679 - bit 7 clear means this NMI was not CIA2's
$E695-$E69B if timer B's high byte fell between the two reads, OR in the timer B flag by hand
$E69C-$E69E nmiMergeIcrFlags: OR in anything that arrived meanwhile
$E69F-$E6C7 transmit half: DEC txBitCounter; negative -> nmiStartNextChar; zero -> stop timer B with CIA2_CRB = $09, DEC uartPendingCount $E0A5, clear txCharActive; then SEC / ROR txShiftRegister and drive PA2 with the bit
$E6C8-$E6D7 CIA2_ICR = $82 (re-enable timer B); recover the flag byte, AND ciaIcrShadow, LSR -> C = timer A, bit 3 = the FLAG line
$E6D8-$E6F0 FLAG line = an RXD start-bit edge: ciaIcrShadow = $83, rxBitCounter = $83 (any negative value), arm timer A at half a bit time, start it, then reload the latch with a whole bit time
$E6F1-$E6FE timer A tick: sample CIA2_PRB bit 0, DEC rxBitCounter; negative = start bit, zero = stop bit, else ROR rxShiftRegister
$E6FF-$E708 shared exit: CIA2_ICR = ciaIcrShadow, PLA / TAX / PLA / RTI
$E709-$E711 start bit must be low; if high -> reportLinkError. Otherwise rxBitCounter = 9
$E712-$E723 stop bit must be high; if low -> reportLinkError. Otherwise clear the stall counter $EB00 and carrierSampleTimer $E045, then pushUartRxRing
$E724-$E726 JSR reportLinkError - framing error, bad stop bit, or receive-ring overflow (C=1 from pushUartRxRing)
$E727-$E732 stop timer A (CIA2_CRA = 0), ciaIcrShadow = $92 (back to waiting for a start bit), branch to the shared exit
  • Reads: CIA2_ICR, CIA2_TB_HI, CIA2_PRA, CIA2_PRB, ciaIcrShadow $E033, $E5BB-$E5C0.
  • Writes: CIA2_ICR, CIA2_CRA, CIA2_CRB, CIA2_PRA bit 2, $E5BB-$E5C0, uartPendingCount $E0A5, ciaIcrShadow $E033, carrierSampleTimer $E045, the stall counter $EB00, and the UART receive ring $E42D/$E42F/$E430..$E443 through pushUartRxRing.
  • Callers: the CPU NMI vector, once installCommNmiVector has written $FFFA/$FFFB.
  • A correction to the shipped comment: the listing calls CIA2_CRB = $09 at $E6AD "stop timer B". $09 is START | RUNMODE(one-shot): it runs one more bit period - the stop bit - and then the CIA clears the START bit itself. That final underflow is the interrupt that drives txBitCounter negative and reaches nmiStartNextChar, which is how back-to-back characters are chained without waiting for the frame tick.

$E69C nmiMergeIcrFlags - range $E69C-$E69F

Three bytes: ORA CIA2_ICR. The tail of the timer B race workaround.

  • In: A = the interrupt flags read so far.
  • Out: A = those flags plus anything that arrived between the handler's two reads.
  • Callers: fall-through from $E69A in this build. (XREF.txt shows a call:E444 for this address, but that entry belongs to trainerAiE000, a different overlay at the same address.)

$E73C queueByteForTransmit - range $E73C-$E743

The single door for outbound bytes.

  • In: A = byte to transmit.
  • Calls: pushUartTxRing $E51B, which silently drops the byte when the 22-byte ring already holds 22 and returns C=1 in that case.
  • Writes: uartTxRing $E417, uartTxCount $E414, uartTxWriteIndex $E416; then INC uartPendingCount $E0A5 - unconditionally, even when the push overflowed. The design relies on startNextTxChar $E632 and flushUartTxRing $E53C to resynchronise $E0A5 when it drifts.
  • Out: A preserved (callers such as sendNextModemCommandChar $E7D5 test what they just sent), X clobbered, Y unchanged, C = 0 if the byte was stored and 1 if it was dropped (no caller looks).
  • Callers: JSR $E7D2 (sendNextModemCommandChar), $E81D (queueSyncByte), $E87C (queueRawSyncByte), $E92A (transmitPacketLoop inside sendPacket).
  • 6551 note: A preserved and X clobbered are both relied on - transmitPacketLoop parks its index in $EAFE across the call precisely because X does not survive.

Addendum: hardware-touching code outside the requested list

Three more places touch the hardware and must be reimplemented or neutralised, even though they were not in the list:

  • $E5D6 serviceCarrierAndSuspendRequest (range $E5D6-$E5FF) and $E5FF rearmCarrierTimer (range $E5FF-$E607). This is where carrier detect is actually read: EOR CIA2_PRB / AND #$10 / ADC #$F0 at $E5F1-$E5F7 turns PB4 into A=$F0 C=0 when DCD is low (carrier) and A=$00 C=1 when it is high, then SEC / ROR produces $F8 (carrier) or $80 (no carrier) in linkStatusSample $E03D. Bit 6 is the carrier flag, bit 7 the "data is flowing" flag. INC carrierSampleTimer $E045 / BPL keeps bit 7 only while a character has arrived within the last ~127 frames; otherwise rearmCarrierTimer re-parks the sample in $E045 (so the timer stays pinned negative) and strips bit 7. It also runs the suspend handshake at $E5D6-$E5E3 and falls straight into startNextTxChar. A 6551 build reads DCD from status bit 5 instead of CIA2_PRB bit 4 and must produce the identical $80/$F8 sample values, because pollCarrierState $E3CB debounces them against linkStatus over 240 frames and the game reads both bytes.
  • $E327-$E331, inside readKeyAndHandleModemHotkeys. The C=+H hot key does LDA dtrToggleMask $E04C / EOR CIA2_PRB / STA CIA2_PRB, toggling PB2 (DTR) and PB5 directly. On a 6551 this becomes a toggle of command-register bit 0.
  • The four raw ring primitives $E4D2 popUartRxRing, $E4EA popUartTxRing, $E502 pushUartRxRing, $E51B pushUartTxRing. Not hardware, but owned by this layer and called from the NMI. Their contracts (C=1 = empty / overflow, A = byte, X clobbered) are relied on by receiveByteTrackZeros $E743, beginByteSyncPhase $E80A, receivePacket $E9E9 and runModemStateMachine $E799.

4. Module RAM owned by the UART layer

Frozen? means the byte is inside $E01D-$E047 and so its disk value must not change, whatever its runtime meaning becomes.

Address Name Meaning in the stock driver Frozen? Needed by a 6551 build
$E033 ciaIcrShadow The CIA2 ICR command byte the module currently wants: $92 idle, $83 while receiving a character, $7F when suspended. Written at $E593, $E6DA, $E72E; read at $E5A4, $E679, $E6CE, $E6FF - and by the main program at $5889, $58A8, $58CD, which stores it back into CIA2_ICR after banking the I/O area out yes (disk value $90) Keep, and pin it to $7F at run time. It is the only byte of this layer the game outside the module reads. If it holds $92, the game will re-enable the CIA2 FLAG NMI after every film-buffer access
$E039 nmiSuspendRequest $C0 from the game before a disk load, 0 after; also written $00/$80 by setCiaNmiMask yes Keep, unchanged semantics
$E03A nmiSuspendAck $80 when the module's interrupt really is off, 0 when running. The game spins on this at $0F6A and $0F7B yes Keep, unchanged semantics
$E03C linkStatus Debounced: bit 6 carrier, bit 7 "characters still arriving". Read by the game at $1B4B (waits for bit 7 to clear before hanging up), $1BB6, $56B1, $65BC yes Keep, unchanged semantics
$E03D linkStatusSample The raw per-frame sample: $80 no carrier, $F8 carrier, bit 7 = data flowing. Read by the game at $1BAB yes Keep; source DCD from ACIA status bit 5
$E042 uartRestartRequest 1 = re-evaluate the connection, $FF = force connectionPhase to 0 yes Keep, unchanged semantics
$E043 carrierDebounceCount Consecutive frames the sample disagreed with linkStatus; 240 accepts the change yes Keep (it lives in pollCarrierState, above the seam)
$E044 txPaceCounter Frames between transmitted characters. Decremented in startNextTxChar $E617, reloaded at $E61C/$E647; beginByteSyncPhase $E820-$E825 forces bit 1 on to space the sync bytes yes Effectively dead. The byte must survive because $E820 writes it, but the pacing gate can become a no-op
$E045 carrierSampleTimer Zeroed by the NMI ($E719) on every good character; INCed once per frame; while it is negative the link counts as quiet. This is what produces bit 7 of linkStatus yes Keep. Same job, driven by "a byte arrived" instead of "a stop bit was good"
$E047 linkErrorCount Bumped by countLinkError $E403 from the NMI receiver; non-zero makes runLinkStateMachine $E7FD restart the byte sync yes Keep. Feed it from the ACIA's framing/parity/overrun status bits
$E04A carrierOverrideFlags Bit 6 = "pretend carrier is present" (the C=+C hot key), for a direct cable with no DCD no Keep - it is the only way to play over a three-wire null-modem lead
$E04B carrierOverrideToggleMask $40, the bit C=+C toggles no Keep
$E04C dtrToggleMask $24 = the CIA2_PRB bits C=+H toggles (PB2 DTR + PB5) no Repurpose as the command-register DTR bit
$E04D userPortDdrbValue $26 - DDRB pattern no Dead
$E04E userPortDtrDropped $22 - PRB with DTR low no Repurpose as the "DTR dropped" command byte
$E04F-$E050 userPortIdleTable Two entries, both $26 - PRB idle pattern by answer/originate no Dead (or repurpose as the idle command byte)
$E051-$E054 serialShiftPatternTable $27 $2F $3F $37 - written to CIA2_SDR; purpose never established no Dead
$E055 baudIndex 0 = 300, 3 = 1200; also the stride into baudParameterTable no Keep as the index; the table it selects changes meaning
$E056 bitPeriodLo Low byte of the CIA timer latch for one bit time no Dead as a timer latch. Natural replacement: the 6551 control-register byte
$E057 bitPeriodHi High byte; $0D50 = 3408 cycles = 300.1 baud, $0353 = 851 = 1201.8 baud on NTSC no Dead
$E058 txPaceReload Frames between transmitted characters: 1 at 300, 2 at 1200 no Dead (see $E044)
$E059-$E05E baudParameterTable Two 3-byte entries {periodLo, periodHi, pacing} no Repurpose as {controlByte, spare, spare} per speed
$E0A4 frameInFlightFlag The $E0A5 value the frame layer saw when it queued a frame; serviceLinkTick $E76E compares the two no (protocol block) Keep, untouched - it is above the seam
$E0A5 uartPendingCount Characters queued for the UART that have not finished transmitting. INC at $E73F; DEC at $E6B0 (character finished) and $E5D2 (character abandoned); forced to 0 at $E634; set to txCharActive at $E53C no (protocol block) Keep - this is the single most important byte of the whole seam. Everything above waits on it: $E1EC, $E26E, $E628, $E76B, $E82D, $E860
$E0A6 packetInFlightCount The byte count of the frame the packet layer handed over no Keep untouched. Structural constraint: sendPacketFsm addresses it as uartPendingCount,X with X = inFlightSlotIndex $E0A8 = 1 at $E1EC, $E265 and $E26E, so $E0A5 and $E0A6 must stay adjacent, in that order. (XREF.txt misses the write at $E265 because it is indexed, and the listing's claim that $E0A6 is only ever written with 0 is wrong for the same reason.)
$E414 uartTxCount Bytes in the 22-byte transmit ring no Keep - the ring is the natural 6551 output queue
$E415/$E416 uartTxReadIndex / uartTxWriteIndex Descending indices no Keep
$E417-$E42C uartTxRing 22-byte descending circular buffer no Keep. It must hold a whole packet: sendPacket $E922-$E934 queues packetLength+1 bytes in one burst, gated only by $E860's "fewer than 2 pending" test
$E42D uartRxCount Bytes in the 20-byte receive ring no Keep
$E42E/$E42F uartRxReadIndex / uartRxWriteIndex Descending indices no Keep
$E430-$E443 uartRxRing 20-byte descending circular buffer, filled only by the NMI at $E71F no Keep. 20 bytes is the whole receive budget between frame ticks - see the hazards in section 7
$E5BB txCharActive Non-zero while a character is being shifted out. Read at $E539, $E5CD, $E60A, $E663; written at $E639, $E6B5 no Keep, redefined as "a byte is sitting in the ACIA transmit register waiting for TDRE". flushUartTxRing and stopCommNmi both depend on it
$E5BC txBitCountReload 9 = 8 data bits plus the stop bit no Dead
$E5BD txBitCounter Bits left in the character being sent no Dead
$E5BE txShiftRegister The character being shifted out, LSB first no Dead
$E5BF rxBitCounter Negative = awaiting the start-bit sample, 9..1 = data bits, 0 = stop bit no Dead
$E5C0 rxShiftRegister The character being shifted in no Dead
$EB00 stall counter Zeroed by the NMI at $E716 on every good character; noteIdlePoll $E997 counts dead service ticks and forces a resync at 16 no (ARQ block) Keep the NMI's zeroing of it. It is the only other thing the receive path owes the layer above

Summary for a 6551 build: $E5BC-$E5C0 (five of the six software-UART bytes), $E056, $E057, $E058, $E04D, $E04F-$E054 and the pacing behaviour of $E044 all become dead. Everything else in the table stays, and $E0A5, $E5BB, $E045, $E03C, $E03D, $E039, $E03A, $E033, $E047 and $EB00 must behave exactly as they do now.

5. How the NMI decides an interrupt is not its own, and how it chains

This is the part the replacement has to copy instruction for instruction in spirit, because the C64's /NMI line is a wired-OR of the RESTORE key, CIA2's /IRQ and the expansion port's /NMI, and the game installs a handler of its own behind this module.

The test. At $E690 the handler does LDA CIA2_ICR. On a 6526, bit 7 of the interrupt data register is set when any enabled source has fired since the register was last read, and reading it clears all the flags. BPL $E679 at $E693 therefore means exactly "no enabled CIA2 source fired - this NMI belongs to someone else". Note the ordering: CIA2_ICR is written with $7F first ($E688-$E68C) to mask every source while the handler works, then CIA2_TB_HI is sampled, then the register is read. The mask write does not clear pending flags; only the read does.

The chain. $E679-$E684, which sits immediately before the handler entry:

E679  LDA ciaIcrShadow      ; $E033 - the mask this module wants
E67C  STA CIA2_ICR          ; undo the $7F we wrote at $E68A
E67F  PLA
E680  TAX                   ; restore X
E681  PLA                   ; restore A
E682  JMP (nmiChainVector)  ; $E031/$E032

Four properties the replacement must preserve:

  1. A and X are restored before the chain, and Y is never touched at all. The handler saves A then X (PHA / TXA / PHA at $E685-$E687) and both exits pop them in the reverse order. Y is neither saved nor used anywhere in the handler, so the chained handler inherits the interrupted Y.
  2. The chain is a JMP (abs), not a JSR and not an RTI. The CPU's NMI stack frame (P, PCH, PCL) is still intact and untouched, so the chained handler owns it and ends with its own RTI.
  3. The vector is $E031/$E032, inside the frozen block. On disk it is $E010, a bare RTI opcode inside the jump table's constant pool. initInterruptVectors at $0BF3/$0BFB patches it to $1298, which is irqRti, a bare RTI in the main program. A replacement must read the vector from $E031/$E032 at run time and must not assume $1298.
  4. The module's own interrupt mask is restored on both exits - $E67C on the chain path and $E702 on the normal path - from ciaIcrShadow $E033.

The 6551 version of the same test. Bit 7 of the ACIA status register at base+1 is the ACIA's IRQ flag, and reading the status register clears it - the direct analogue of reading CIA2_ICR. So:

        PHA
        TXA
        PHA
        LDA base+1            ; status; reading it clears the ACIA IRQ flag
        BPL notMine           ; bit 7 clear - not the ACIA's interrupt
        ... service TDRE (bit 4), RDRF (bit 3), and the error bits 2/1/0 ...
        LDA $E033
        STA CIA2_ICR          ; keep CIA2 masked exactly as the stock code does
        PLA
        TAX
        PLA
        RTI
notMine:
        LDA $E033
        STA CIA2_ICR
        PLA
        TAX
        PLA
        JMP ($E031)

with two additions that the stock design did not need:

  • Do not write $7F to CIA2_ICR on entry and then restore $E033 unless $E033 is itself $7F. Simplest correct policy: set $E033 = $7F once at open time and never change it; then both the handler and the main program's $588C/$58AB/$58D0 restores keep CIA2 permanently silent, and the only NMI source left is the cartridge.
  • Bank the I/O area in yourself. readFilmByte $5874, writeFilmByte $5893 and copyPageUnderIo $58B2 run with SEI and $01 = $34, which removes $D000-$DFFF - including the SwiftLink - from the address space. SEI does not mask NMI, and writing $7F to CIA2_ICR (which is what those routines do to silence the stock driver) has no effect on an expansion-port NMI. copyPageUnderIo holds that state for a 256-byte copy loop, roughly 2600 cycles, which at 38400 baud is about ten character times. The replacement handler must therefore save $01, force it to $35, do its work, and restore $01 before either exit. This is not optional: $EE4A loadFilmStartSnapshot, in the frozen tail of this very module, calls copyPageUnderIo twice.

One thing the stock handler does that is easy to miss: on every successfully received character it zeroes both carrierSampleTimer $E045 ($E719) and the stall counter $EB00 ($E716). The first feeds bit 7 of linkStatus, which the game waits on at $1B4B; the second is what stops noteIdlePoll $E997 from tearing the connection down after sixteen dead service ticks. A 6551 receive path that forgets either one will produce a link that hangs up on itself.

Claim under test: SwiftLink fits a 3.6864 MHz crystal, twice what the 6551 expects, so every rate in the control register's low nibble comes out doubled.

Conclusion: the claim is correct, and the mapping given in the task brief is right.

Second source: cc65's own SwiftLink serial driver, libsrc/c64/ser/c64-swlink.s, whose BaudTable maps the requested rate to the control-register value the hardware needs:

Requested cc65 control value 6551 nominal rate for that value doubled
150 $02 75 150
300 $05 150 300
600 $06 300 600
1200 $07 600 1200
2400 $08 1200 2400
3600 $09 1800 3600
4800 $0A 2400 4800
7200 $0B 3600 7200
9600 $0C 4800 9600
19200 $0E 9600 19200
38400 $0F 19200 38400

Every row is exactly 2x the nominal 6551 rate, with no exceptions, which is only consistent with a doubled reference clock. ($0D, nominal 7200, would give 14400; cc65 has no constant for it and leaves it unused - that is the only gap, and it is a gap in cc65's table, not in the hardware.) Corroborating hardware documentation: the GLINK232 and Turbo232 cartridges both state that they use a 3.6864 MHz oscillator, "double the standard for a 6551", specifically in order to match the SwiftLink baud rate table.

So: control byte = $10 OR rateBits (bit 4 = internal baud rate generator, bits 6-5 = 00 = 8 data bits, bit 7 = 0 = 1 stop bit), with $05 = 300, $07 = 1200, $08 = 2400, $0C = 9600, $0E = 19200, $0F = 38400.

Two caveats worth carrying into the implementation:

  • Some SwiftLinks were rebuilt with the standard 1.8432 MHz crystal (cc65's header calls these the "hacked" SwiftLink). On those the table is the plain 6551 one and every rate is half of the above. If the driver ends up with a user-visible speed setting, it is worth documenting which crystal the numbers assume.
  • The doubling is a property of the crystal, not of the chip, so it applies identically to a Turbo232 in SwiftLink-compatible mode. Turbo232's extra rates come from a separate register and are not reachable through the control register's low nibble.

7. Hazards checklist for the 6551 implementation

Two notes from later measurement. Hazard 2's "at 38400 baud a frame is about 200 character times" is wrong: at 3840 characters a second and 59.826 frames a second it is 64.2 (highRateReport.md section 1). The conclusion the hazard draws is unaffected - the 20-byte ring is still outrun - but the problem is three times smaller than the sentence implies, which is why 19200 survives. Hazard 8's "same 8N1 framing" is now true only of the data bits: the driver adopts the port's own rate, stop bits and clock source, forcing eight data bits and nothing else, so two SwiftLink ends agree on whatever their control registers hold while a stock user-port peer can only ever be 8N1 (portCompatReport.md sections 5 to 8, testReport.md sections 11.4 and 11.5). Parity is not adopted, because it lives in the command register the driver rewrites from constants. The text below is left as it was written.

  1. $E0A5 is the heartbeat. Increment it in queueByteForTransmit, decrement it when the ACIA takes the byte, and make sure it can reach 0. If it never reaches 0, serviceLinkTick $E76E never clears $E0A4/$E0A6, sendPacketFsm $E26E never completes an exchange, and the game locks in lock-step forever. Note that on a 6551, "TDRE came back" means the holding register is free, one byte ahead of the wire - harmless here, since nothing above measures real wire time.
  2. The receive ring is 20 bytes, only the frame tick drains it, and 17 of those bytes can be spoken for at once. awaitWholePacket $E9D9 does CPX uartRxCount / BCS countStalledPoll: it refuses to copy a packet body until the whole payload plus its check byte is already sitting in the ring. The length-code search at $E9CC yields a payload length of 1..16, so up to 17 bytes must be resident simultaneously and only 3 bytes of the 20-byte ring are genuine headroom. At 300 baud a frame holds at most one character; at 38400 baud a frame is about 200 character times. Either the driver must throttle (drop RTS by writing the command register with bits 3-2 = 00) or the ring has to be enlarged - and it cannot be enlarged in place without moving $E444 onwards. Practical answer: keep RTS handshaking honest, and only advertise rates the frame-tick drain rate can sustain.
  3. Overrun, framing and parity (status bits 2, 1, 0) must reach reportLinkError $E3BC. That is the only thing that sets linkErrorCount $E047, and that is the only thing that makes runLinkStateMachine $E7FD restart the byte sync after a disk load has eaten part of a packet.
  4. The disk-load suspend really must stop the interrupt. The fast loader at $0804 is cycle-exact. $E039/$E03A is the handshake; stopCommNmi is the routine; the 6551 equivalent is a command-register write with receiver IRQ disabled. Bytes arriving during the load are lost, and the ARQ layer is expected to recover - that is how the stock driver behaves too.
  5. STA $DF59,Y at $E358 must go. See loadBaudParameters above: its dummy read hits $DF58 = the data register on a $DF00-strapped SwiftLink.
  6. Do not touch CIA2_PRA. PA0/PA1 are the VIC bank select and PA3-PA5 are the serial bus. The stock code only ever read-modify-writes bit 2; a 6551 build should leave the register alone entirely and make $E56B (the tail setCiaNmiMask jumps to) a plain RTS or a harmless equivalent.
  7. $E033 must end up holding $7F at run time while keeping its $90 disk byte, so that the main program's post-film-access restores at $588C, $58AB and $58D0 do not re-arm a CIA2 NMI source behind the driver's back.
  8. Wire compatibility. Nothing above the seam changes, so the module stays byte-for-byte compatible with the stock driver on the wire at the same line rate: same 8N1 framing, same $00/ $FF byte-sync handshake, same length/control/sequence/checksum frame, same retransmit rules. Two machines can only talk if both ends use the same speed, because the speed is not negotiated (the only automatic change is the CONNECT 1200 sniff at $E7A9, which is a Hayes-modem behaviour).
  9. W65C51N replicas. Real SwiftLinks use a 6551 whose TDRE bit works. Some modern rebuilds use a WDC W65C51N, on which TDRE reads as permanently set; a driver that polls TDRE will overrun the transmitter on those boards and needs a timed delay per character instead. Worth a note in the final driver's comments even if the target is a genuine cartridge or VICE's emulation.