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.mdfor 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-$E540means the routine occupies$E534..$E53Finclusive 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$E000with 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).Yis 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. TheA = $FFreturn 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 aCONNECT 1200result code); fall-through from$E350(STX baudIndex) on the C=+3 and C=+1 hot keys.XREF.txtlists 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 = $E056so one register both counts and indexes.STA abs,Yalways performs a dummy read at(base AND $FF00) OR ((baseLo+Y) AND $FF), i.e. at$DF56,$DF57and$DF58. On a SwiftLink strapped to$DF00,$DF58 AND 3 = 0is 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/$DF00I/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:
$E414to 0,$E415walked 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 emptypopUartTxRing), N/Z from theLDA 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 toCIA2_PRA,X = isOriginateMode,Yunchanged, N/Z from theORA 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-$E573is a separate entry:setCiaNmiMaskends withJMP $E56B. Any replacement must keep a valid three-instruction "raise TXD / RTS" tail at$E56Bor 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$0Dwhen the transmit interrupt is wanted). TheCIA2_SDRwrite, the timer A latch and theCIA2_PRAbit 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$E2A2for$E003X>=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 atcommNmiHandler $E685. The KERNAL ROM is banked out ($01 = $35), so$FFFA/$FFFBreally is RAM. - Out:
A = $85,X = $E6, N=1 Z=0 (fromLDX #$E6), C unchanged. - Callers: JSR
$E2CC(openCommLink, only on a cold open whenisLinkActivewas 0). - 6551 note: the constants
$85/$E6are the only thing that has to change if the handler moves, but it must not move: nothing else does, and keeping it at$E685keeps 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-$E5C0and returns here normally. - Writes:
$E5BB-$E5C0 = 0;uartRestartRequest $E042 = $01("re-evaluate the connection", as opposed to$FFwhich forces phase 0);carrierSampleTimer $E045 = $92. - Falls through into
setCiaNmiMaskwithA = $92, which arms the CIA2 FLAG line (the RXD start-bit edge) and timer B, and as a side effect writes$00to bothnmiSuspendRequest $E039andnmiSuspendAck $E03A. - Out: as
setCiaNmiMask-A= theCIA2_PRAvalue,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 = 1and$E039/$E03A = 0. A 6551 build must still produce exactly those, becauserunModemStateMachine $E758consumes$E042and the game'sresumeCommModule $0F7Bspins until$E03Abit 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(theBITat$E596clears any pending flags),ciaIcrShadow $E033. - Writes:
ciaIcrShadow $E033 = A;CIA2_ICR = A;CIA2_CRA = 0andCIA2_CRB = 0(stopping both timers); thennmiSuspendRequest $E039andnmiSuspendAck $E03Aboth set to(A EOR $FF) AND $80- which is$00for$92/$83and$80for$7F. - Retry loop:
CMP ciaIcrShadow / BCC $E593. If the NMI overwrote the shadow with a larger mask while we were storing ($83or$92over a$7F), the whole routine repeats. This is a real race, not decoration. - Out: exits through
JMP $E56B, soA= the value written toCIA2_PRA,X = 0, C set by the finalAND #$80, N/Z from theORA CIA2_PRA. - Callers: JSR
$E5CA(stopCommNmi), fall-through fromrestartUart $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/$E03Aand$E033writes must be kept exactly as they are - see section 5 for why$E033matters 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 torestartUart. - 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
restartUartcalls it andflushUartTxRingreads$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 ofnmiSuspendAck, shifted out by theASLat$E5D9. - Out: if C=1 (already suspended) it branches to the
RTSat$E5D5insidestopCommNmiand nothing happens; if C=0 it falls intostopCommNmi. - Callers: branch
BMI $E5DDinserviceCarrierAndSuspendRequest. Never called with JSR.
$E5C3 stopCommNmi - range $E5C3-$E5D6
Tears the UART down.
- In:
txCharActive $E5BB. - Writes:
uartRestartRequest $E042 = $FF(forcesconnectionPhaseback to 0 when the state machine next runs); callssetCiaNmiMaskwith$7F, soCIA2_ICRis cleared, both timers stopped,$E033 = $7Fand$E039 = $E03A = $80; then, if a character was half-way out,DEC uartPendingCount $E0A5because that character will never finish. - Out:
A= 0 or$E5BB's value,X = 0, RTS at$E5D5. - Callers: JSR
$E2AB(commLinkControlDispatch, reached for$E003X=2 and, after the hang-up string has drained, X>=3), fall-through fromsuspendUartIfRunning. - Ordering that matters:
$E042 = $FFis written before the mask is cleared, and theuartPendingCountfix-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:
LDA CIA2_CRB / ORA txCharActive / LSR- give up if timer B's START bit is set or a character is already in flight.- If
connectionPhase $E040 == 1(modem command / terminal mode), skip guards 3 and 4 entirely: terminal-mode characters go out unpaced and without carrier. 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.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.LDA uartPendingCount $E0A5- give up if zero.JSR popUartTxRing; if the ring was empty although the count said otherwise, force$E0A5 = 0and 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 = $11on the success path,A/flags meaningless on the give-up paths (every one of them branches to$E662).X = 2on 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
$E0A5is non-zero and, outside phase 1, only with carrier. Guard 4 is the reason a null-modem player needs the C=+C carrier override ($E04Abit 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$E044must still toleratebeginByteSyncPhaseforcing 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 fromstartNextTxChar $E653.
$E657 setBitPeriod - range $E657-$E663
- In:
A= high byte of the interval,X= 0 or 2,bitPeriodLo $E056. - Writes:
CIA2_TA_HI,XthenCIA2_TA_LO,X, i.e.$DD05/$DD04for X=0 and$DD07/$DD06for X=2. The low byte is always the fullbitPeriodLo; only the high byte varies (the caller passes$01for the short first transmit interval andbitPeriodHi >> 1for the half-bit start-bit delay). - Out:
A = $11- the "force load + start" byte the caller stores intoCIA2_CRAorCIA2_CRB.Xunchanged, N=0 Z=0. - Callers: JSR
$E64E(startNextTxChar), JSR$E6E6(the NMI's start-bit path), fall-through fromsetBitPeriodFull. - 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 $E5BDfrom$E5BC(9), then readCIA2_PRAand branch to$E6C3to clear bit 2 - the start bit. TheBNE $E671is an unconditional branch in practice becauseCIA2_PRAcan never read 0 (the VIC bank bits are in it). - If not:
JSR startNextTxChar, thenCLC / BCC $E6C8into the receive half. - Out: falls into the shared NMI tail; nothing returns to a caller.
- Callers: branch
BMI $E6A7in 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_PRAbit 2,$E5BB-$E5C0,uartPendingCount $E0A5,ciaIcrShadow $E033,carrierSampleTimer $E045, the stall counter$EB00, and the UART receive ring$E42D/$E42F/$E430..$E443throughpushUartRxRing. - Callers: the CPU NMI vector, once
installCommNmiVectorhas written$FFFA/$FFFB. - A correction to the shipped comment: the listing calls
CIA2_CRB = $09at$E6AD"stop timer B".$09isSTART | 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 drivestxBitCounternegative and reachesnmiStartNextChar, 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
$E69Ain this build. (XREF.txtshows acall:E444for this address, but that entry belongs totrainerAiE000, 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; thenINC uartPendingCount $E0A5- unconditionally, even when the push overflowed. The design relies onstartNextTxChar $E632andflushUartTxRing $E53Cto resynchronise$E0A5when it drifts. - Out:
Apreserved (callers such assendNextModemCommandChar $E7D5test what they just sent),Xclobbered,Yunchanged,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(transmitPacketLoopinsidesendPacket). - 6551 note:
Apreserved andXclobbered are both relied on -transmitPacketLoopparks its index in$EAFEacross 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 #$F0at$E5F1-$E5F7turns PB4 into A=$F0C=0 when DCD is low (carrier) and A=$00C=1 when it is high, thenSEC / RORproduces$F8(carrier) or$80(no carrier) inlinkStatusSample $E03D. Bit 6 is the carrier flag, bit 7 the "data is flowing" flag.INC carrierSampleTimer $E045 / BPLkeeps bit 7 only while a character has arrived within the last ~127 frames; otherwiserearmCarrierTimerre-parks the sample in$E045(so the timer stays pinned negative) and strips bit 7. It also runs the suspend handshake at$E5D6-$E5E3and falls straight intostartNextTxChar. A 6551 build reads DCD from status bit 5 instead ofCIA2_PRBbit 4 and must produce the identical$80/$F8sample values, becausepollCarrierState $E3CBdebounces them againstlinkStatusover 240 frames and the game reads both bytes.$E327-$E331, insidereadKeyAndHandleModemHotkeys. The C=+H hot key doesLDA 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 byreceiveByteTrackZeros $E743,beginByteSyncPhase $E80A,receivePacket $E9E9andrunModemStateMachine $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:
- A and X are restored before the chain, and Y is never touched at all. The handler saves A then
X (
PHA / TXA / PHAat$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. - The chain is a
JMP (abs), not aJSRand not anRTI. The CPU's NMI stack frame (P, PCH, PCL) is still intact and untouched, so the chained handler owns it and ends with its ownRTI. - The vector is
$E031/$E032, inside the frozen block. On disk it is$E010, a bareRTIopcode inside the jump table's constant pool.initInterruptVectorsat$0BF3/$0BFBpatches it to$1298, which isirqRti, a bareRTIin the main program. A replacement must read the vector from$E031/$E032at run time and must not assume$1298. - The module's own interrupt mask is restored on both exits -
$E67Con the chain path and$E702on the normal path - fromciaIcrShadow $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
$7FtoCIA2_ICRon entry and then restore$E033unless$E033is itself$7F. Simplest correct policy: set$E033 = $7Fonce at open time and never change it; then both the handler and the main program's$588C/$58AB/$58D0restores keep CIA2 permanently silent, and the only NMI source left is the cartridge. - Bank the I/O area in yourself.
readFilmByte $5874,writeFilmByte $5893andcopyPageUnderIo $58B2run withSEIand$01 = $34, which removes$D000-$DFFF- including the SwiftLink - from the address space.SEIdoes not mask NMI, and writing$7FtoCIA2_ICR(which is what those routines do to silence the stock driver) has no effect on an expansion-port NMI.copyPageUnderIoholds 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$01before either exit. This is not optional:$EE4A loadFilmStartSnapshot, in the frozen tail of this very module, callscopyPageUnderIotwice.
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.
6. The SwiftLink baud rate doubling - verified
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.mdsection 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.mdsections 5 to 8,testReport.mdsections 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.
$E0A5is the heartbeat. Increment it inqueueByteForTransmit, decrement it when the ACIA takes the byte, and make sure it can reach 0. If it never reaches 0,serviceLinkTick $E76Enever clears$E0A4/$E0A6,sendPacketFsm $E26Enever 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.- The receive ring is 20 bytes, only the frame tick drains it, and 17 of those bytes can be spoken
for at once.
awaitWholePacket $E9D9doesCPX 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$E9CCyields 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$E444onwards. Practical answer: keep RTS handshaking honest, and only advertise rates the frame-tick drain rate can sustain. - Overrun, framing and parity (status bits 2, 1, 0) must reach
reportLinkError $E3BC. That is the only thing that setslinkErrorCount $E047, and that is the only thing that makesrunLinkStateMachine $E7FDrestart the byte sync after a disk load has eaten part of a packet. - The disk-load suspend really must stop the interrupt. The fast loader at
$0804is cycle-exact.$E039/$E03Ais the handshake;stopCommNmiis 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. STA $DF59,Yat$E358must go. SeeloadBaudParametersabove: its dummy read hits$DF58= the data register on a$DF00-strapped SwiftLink.- 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 tailsetCiaNmiMaskjumps to) a plain RTS or a harmless equivalent. $E033must end up holding$7Fat run time while keeping its$90disk byte, so that the main program's post-film-access restores at$588C,$58ABand$58D0do not re-arm a CIA2 NMI source behind the driver's back.- 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/$FFbyte-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 theCONNECT 1200sniff at$E7A9, which is a Hayes-modem behaviour). - 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.