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

721 lines
41 KiB
Markdown

# spec_link.md - what surrounds the UART in the $E000 opponent module
Reference for the SwiftLink replacement of the bit-banged user-port UART in
`disassembly/game/modemDriverE000.s`. Everything here was read out of the listings in
`disassembly/game/` (mainly `modemDriverE000.s` and `mainProgram0800.s`); every address quoted was
checked against them. Nothing in this document proposes changing the layers above the UART - the
point of the exercise is that they stay bit-for-bit the same so the two builds remain wire
compatible at the same line speed.
Read `docs/overview.md` section 9 first for the shape of the module.
## 0. The layer boundary
`swiftlink/checkAbi.py` freezes three regions: `$E000-$E017` (the jump table), `$E01D-$E047`
(variables the game reads and writes) and `$EC00-$EFFF` (utilities the map generator overlay calls).
The rest is ours, but only the following is genuinely hardware specific.
| Range | Routine(s) | Bytes | Keep the entry address? |
|---|---|---|---|
| `$E534-$E53F` | `flushUartTxRing` | 12 | yes - called from `$E791` and `$E80F` |
| `$E540-$E573` | `configureUserPortLines` | 52 | yes - called from `$E2D5` and `$E387` |
| `$E574-$E57A` | `dropDtrLine` | 7 | yes - called from `$E38D` |
| `$E57B-$E585` | `installCommNmiVector` | 11 | yes - called from `$E2CC` |
| `$E586-$E592` | `restartUart` | 13 | yes - called from `$E2CF` and `$E5E1` |
| `$E593-$E5B5` | `setCiaNmiMask` | 35 | internal (only `$E590` fall-through and `$E5CA`) |
| `$E5B6-$E5C0` | `clearUartState` + its 6 inline data bytes | 11 | internal |
| `$E5C1-$E5C2` | `suspendUartIfRunning` | 2 | internal (`$E5DD`) |
| `$E5C3-$E5D5` | `stopCommNmi` | 19 | yes - called from `$E2AB` |
| `$E5D6-$E5FE` | `serviceCarrierAndSuspendRequest` | 41 | yes - called from `$E3CB` |
| `$E5FF-$E606` | `rearmCarrierTimer` | 8 | internal |
| `$E607-$E653` | `startNextTxChar` | 77 | internal (`$E604` fall-through, `$E673`) |
| `$E654-$E662` | `setBitPeriodFull` / `setBitPeriod` | 15 | internal |
| `$E663-$E684` | `nmiStartNextChar` | 34 | internal |
| `$E685-$E732` | `commNmiHandler` | 174 | internal (reached through `$FFFA/$FFFB`) |
| `$E73C-$E742` | `queueByteForTransmit` | 7 | yes - called from `$E7D2`, `$E81D`, `$E87C`, `$E92A` |
That is `$E534-$E732` plus `$E73C-$E742`, 518 bytes in all. Six 6551 registers replace all of it,
so there will be a lot of padding.
Everything else in the module is untouched by the hardware change:
* the four ring buffers and their push/pop routines, `$E40F-$E533` (the two "UART" rings are just
memory; only their producer and consumer change);
* the three indirect link-I/O vectors `$E3B6/$E3B9/$E3BC` and the table at `$E3BF`;
* the frame/ARQ layer `$E909-$EAB2` and its state block `$EAB7-$EB02`;
* the packet/chat layer `$E111-$E2FF`.
Three data bytes are the interface between the baud table and the UART: `$E056`, `$E057` and `$E058`
(section 4). Their meaning is private to the module, so the SwiftLink build may redefine them.
The single most important software contract across the boundary is **`uartPendingCount` at `$E0A5`**:
the number of characters handed to the transmitter that have not physically left the machine. Four
places above the UART read it and will misbehave if it lies:
* `$E76B` `serviceLinkTick` compares it with `frameInFlightFlag` `$E0A4` to decide that a queued
packet has drained and a new one may be built;
* `$E82D` `continueByteSync` waits for it to reach 0 before sending the next `$00` sync byte;
* `$E860` `servicePacketTransmit` refuses to start anything new while it is 2 or more;
* `$E539` `flushUartTxRing` resets it from `txCharActive` `$E5BB`.
`queueByteForTransmit` `$E73C` increments it; the stock NMI decrements it at `$E6B0` when a character
finishes and zeroes it at `$E634` if the ring unexpectedly runs dry. A 6551 build must keep the same
accounting, counting a byte as pending until TDRE says the transmit register has taken it.
`$E044` `txPaceCounter` is a transmitter throttle owned by the UART layer, but `beginByteSyncPhase`
reaches into it: `$E820-$E825` ORs bit 1 in so that at least two transmit slots separate the sync
bytes. The replacement must tolerate that write (and may simply ignore the byte).
## 1. The suspend/resume handshake ($E039 request, $E03A acknowledge)
The fast loader (`$0804` and the drive code) transfers bytes with a two-bit handshake on CIA2 port A
timed against `$D012`. It cannot survive an NMI, so before any disk access the game parks the
opponent module and waits for it to confirm.
### Game side
`suspendCommModule` `$0F5F`:
```
0F5F pha ; A must survive
0F60 lda $E03B ; isLinkActive - no link (or the trainer) means nothing to suspend
0F63 beq $0F6F
0F65 lda #$C0 ; the request code
0F67 sta $E039 ; nmiSuspendRequest
0F6A lda $E03A ; nmiSuspendAck ...
0F6D bpl $0F6A ; ... spin until bit 7 is SET
0F6F pla
0F70 rts
```
`resumeCommModule` `$0F71` is the mirror image: it stores `$00` into `$E039` at `$0F78` and spins at
`$0F7B` until bit 7 of `$E03A` is **clear**.
Both are reached through `suspendCommForDiskAccess` `$10CB` and `resumeCommAfterDiskAccess` `$10D1`,
which also put "WORKING..." on the status line. Callers: `$0B3D`, `$1030`, `$103E`, `$1044`,
`$1086`, `$10A8`, `$10DC` in the main program and `$7B7D`, `$7DF7`, `$7DFE`, `$7F57`, `$7F95`,
`$815E`, `$81A2`, `$81AF` in the map-generator overlay. `waitForGameDiskInserted` `$10DC` suspends
and deliberately leaves the module suspended for its caller.
### Exact values
| Byte | Value | Meaning |
|---|---|---|
| `$E039` `nmiSuspendRequest` | `$00` | no request - run normally |
| | `$C0` | the game wants the module's interrupts off (only bit 7 is ever tested) |
| | `$80` | written by the module itself when it parks (see below) |
| `$E03A` `nmiSuspendAck` | `$00` | interrupts are running |
| | `$80` | interrupts are off; the disk may be touched |
Only bit 7 matters on both bytes. `$E5DD` tests the request with `BMI`, and `$0F6D`/`$0F7E` test the
acknowledge with `BPL`/`BMI`.
### Module side, and where it runs
The handshake is served from `pollLinkStatus` `$E00C` -> `pollCarrierState` `$E3CB` ->
`serviceCarrierAndSuspendRequest` `$E5D6`, which the raster IRQ calls once per frame at `$112C`
(right after the `$E000` X=0 tick at `$1129`). **The game's spin loops therefore depend on the
raster IRQ still running**; the module never answers from the store itself. (The solo trainer
answers from its `$E000` X=0 entry instead, at `$E059-$E05C` of `trainerAiE000.s`, by copying `$E039`
straight into `$E03A`. Either entry is acceptable - the raster IRQ calls both, in that order.)
The stock sequence at `$E5D6`:
```
E5D6 lda $E03A ; acknowledge
E5D9 asl a ; C = "already suspended"
E5DA lda $E039 ; request
E5DD bmi $E5C1 ; bit 7 set -> suspendUartIfRunning
E5DF bcc $E5E4 ; no request and not suspended -> carry on to the carrier sample
E5E1 jsr $E586 ; no request but still suspended -> restartUart
E5E4 ... ; carrier sampling (section 2)
```
`suspendUartIfRunning` `$E5C1` is a two-byte `BCS $E5D5` that returns at once when the suspend has
already been acknowledged, otherwise it falls into `stopCommNmi` `$E5C3`.
`stopCommNmi` does three things: writes `$FF` to `uartRestartRequest` `$E042` (which forces
`connectionPhase` `$E040` back to 0 on the next tick of the modem state machine), calls
`setCiaNmiMask` with `$7F` to disable every CIA2 interrupt source, and decrements `uartPendingCount`
`$E0A5` if a character was half way out of the transmitter, because that character will never finish.
`setCiaNmiMask` `$E593` is where the two handshake bytes are actually written:
```
E5A9 eor #$FF ; $92/$83 -> $00, $7F -> $80
E5AB and #$80
E5AD sta $E039 ; nmiSuspendRequest
E5B0 sta $E03A ; nmiSuspendAck
```
so an enable mask clears both bytes and the disable mask sets both to `$80`. Note that the module
overwrites the game's `$C0` request with its own `$80`; that is deliberate and harmless, because on
the next frame `$E5DD` still sees bit 7 set and `$E5C1` short-circuits.
`restartUart` `$E586` is the resume path: it zeroes the six bit-level UART state bytes `$E5BB-$E5C0`,
writes 1 to `uartRestartRequest` `$E042` ("re-evaluate the connection but keep the phase"), primes
`carrierSampleTimer` `$E045` with a negative value, and falls into `setCiaNmiMask` with `$92`, which
clears both handshake bytes. `restartUart` is also the cold-start path, called from `openCommLink`
at `$E2CF`.
### What the SwiftLink UART layer must do
**On suspend** (`$E039` bit 7 set and `$E03A` bit 7 clear):
1. Silence the ACIA completely. The expansion-port NMI is non-maskable, so it is not enough to set
`SEI`; the interrupt source itself must go. Set command-register bit 1 (receiver IRQ disable) and
leave the transmit control bits at "TX IRQ off". Do **not** clear command bit 0 - that is DTR,
and dropping it makes a Hayes modem hang up (section 3).
2. Read the status register once afterwards so any already-latched IRQ flag is cleared.
3. Decide what to do about the character that may be mid-transmission. The stock code decrements
`$E0A5` for it (`$E5CD-$E5D4`); the same reasoning applies, since after a disk load the peer will
have to resynchronise anyway.
4. Set `uartRestartRequest` `$E042 = $FF` so the connection falls back to phase 0, exactly as
`stopCommNmi` does. The frame layer will then rebuild byte sync, which is the right answer
because incoming bytes were dropped on the floor while the drive was busy.
5. Set `$E039 = $80` and `$E03A = $80`.
**On resume** (`$E039` bit 7 clear and `$E03A` bit 7 set):
1. Re-enable the receive interrupt (command bit 1 clear, bit 0 still set).
2. Throw away whatever the receiver may have latched during the load - read the data register once
and clear the overrun/framing/parity bits by reading status.
3. Clear the module's own bit-level state, set `uartRestartRequest` `$E042 = $01`, and re-prime the
quiet-line timer `carrierSampleTimer` `$E045` with a negative value, as `restartUart` does.
4. Set `$E039 = $00` and `$E03A = $00`.
**Do not touch CIA2 at all.** The stock `configureUserPortLines` writes `CIA2_CRA = $51` (starts
timer A with the serial port in output mode), `CIA2_TA_LO/HI`, `CIA2_SDR`, `CIA2_DDRB = $26`,
`CIA2_PRB` and `CIA2_PRA` bit 2. The fast loader owns CIA2 port A bits 4-7 and its own DDR; leaving
CIA2 alone removes a whole class of interference that the stock driver had to suspend around. The
SwiftLink build still has to suspend, because the ACIA's NMI would wreck the loader's cycle timing
just as thoroughly.
**The NMI chain must be preserved.** `installCommNmiVector` `$E57B` writes `$E685` into `$FFFA/$FFFB`
(the ROMs are banked out, so the vectors are RAM). When the NMI was not the module's, the stock
handler restores the interrupt mask and jumps through `nmiChainVector` `$E031/$E032`, which the game
patches to `$1298` (a bare `RTI`) at `$0BF3/$0BFB`. `$E031/$E032` are inside the frozen block, so
the SwiftLink handler must keep chaining through them - that is the path the RESTORE key takes.
## 2. Carrier detection
### Where the bit comes from in the stock driver
`serviceCarrierAndSuspendRequest` `$E5D6`, after the suspend handshake:
```
E5E4 lda $E03B ; isLinkActive - if the link was never opened, do not sample at all
E5E7 beq $E662 ; (an RTS)
E5E9 lda $E04A ; carrierOverrideFlags
E5EC bmi $E604 ; bit 7 would publish A verbatim (no code in the image ever sets it)
E5EE asl a ; bit 6 -> bit 7
E5EF bmi $E5F8 ; the C= + C override is on: report carrier without reading the line
E5F1 eor $DD01 ; CIA2_PRB, through $E04A as a polarity mask (normally $00)
E5F4 and #$10 ; PB4
E5F6 adc #$F0 ; PB4 high -> A=$00 C=1 ; PB4 low -> A=$F0 C=0
E5F8 sec
E5F9 ror a ; -> $80 no carrier, $F8 carrier, $C0 override
E5FA inc $E045 ; carrierSampleTimer
E5FD bpl $E604 ; still positive: a character arrived recently, keep bit 7
E5FF sta $E045 ; quiet line: park the (negative) sample here so the timer stays negative
E602 and #$7F ; and strip the "data flowing" bit
E604 sta $E03D ; linkStatusSample
```
**The user-port bit is `CIA2_PRB` bit 4 (PB4), user-port pin H, the standard C64 RS-232 DCD input,
and carrier means PB4 reads LOW.** That inversion is the level converter's doing: on the user port
the RS-232 control lines arrive TTL-inverted, so an asserted (positive) DCD at the connector shows up
as a 0 at the CIA. With no interface plugged in, PB4 floats/pulls high and the driver reports "no
carrier" - which is why the C= + C override exists.
`$E04A` `carrierOverrideFlags` doubles as a polarity mask because of the `EOR $DD01`: setting bit 4
there would invert the sense of the DCD test. No code writes bit 4; only bit 6 is toggled, by the
C= + C hot key through `$E04B` `carrierOverrideToggleMask` = `$40`.
The three possible raw samples, before the quiet-line adjustment:
| Condition | `$E03D` |
|---|---|
| PB4 high (no carrier) | `$80` |
| PB4 low (carrier) | `$F8` |
| C= + C override active | `$C0` |
and with bit 7 stripped when the line has been quiet: `$00`, `$78`, `$40`.
### The "data is flowing" bit (bit 7)
`carrierSampleTimer` `$E045` is incremented once per frame here and zeroed by the NMI receiver at
`$E719` on every character that arrives with a good stop bit. Starting from 0 it takes 128 frames
(about 2.1 s NTSC, 2.6 s PAL) to go negative, at which point `rearmCarrierTimer` `$E5FF` stores the
sample into it - a negative value - so it stays negative until the next good character resets it to
0. Bit 7 of `$E03D` therefore means "a character has been received in the last ~2 seconds".
The SwiftLink build must reproduce this: zero `$E045` from the receive path each time a byte is taken
out of the ACIA's data register with no framing/parity error.
### Debouncing, and how it reaches `$E03C`
`pollCarrierState` `$E3CB` (the `$E00C` implementation):
```
E3CB jsr $E5D6 ; suspend handshake + fresh sample
E3CE lda $E03D ; linkStatusSample
E3D1 cmp $E03C ; linkStatus
E3D4 beq $E3E3 ; agrees - reset the debounce count
E3D6 inc $E043 ; carrierDebounceCount
E3D9 ldx $E043
E3DC cpx #$F0 ; 240 consecutive disagreeing samples
E3DE bcc $E3EA
E3E0 sta $E03C ; accept the new value
E3E3 ldx #$00
E3E5 stx $E043
E3E8 beq $E3F2
E3EA and #$80 ; meanwhile take only the "data flowing" bit ...
E3EC ora $E03C ; ... and merge it in (this can only SET bit 7, never clear it)
E3EF sta $E03C
E3F2 rts
```
240 frames is about 4.0 s NTSC / 4.8 s PAL - deliberately slow, because a modem's DCD twitches during
negotiation. It applies to the first acquisition too, so "WAITING FOR CONNECTION..." sits on screen
for at least four seconds after carrier appears.
Note the asymmetry at `$E3EA`: between accepted transitions the merge can only turn bit 7 on. Bit 7
of `$E03C` therefore only ever falls when a whole new sample is accepted, i.e. after 240 frames.
`hangUpModemSetState` at `$1B4B` relies on exactly that (see below).
### The three bytes the rest of the game reads
| Byte | Written by | Read by |
|---|---|---|
| `$E03B` `isLinkActive` | `openCommLink` `$E2D2` (`INC` -> 1); cleared by `clearLinkVars` `$E2AE`; forced to 0 by the game at `$1B01` in a trainer game | `$0F60`, `$0F71`, `$1331`, `$1352`, `$1A40`, `$1A8C`, `$1B20`, `$1B38`, `$1B5F`, `$4E46`, `$795D`, and `$E13E` inside the module |
| `$E03C` `linkStatus` | `pollCarrierState` only | `$1B4B` (bit 7), `$1BB6` (bit 6), `$56B1` (bit 6), `$65BC` (bit 6); inside the module `$E2F8`, `$E622`, `$E787`, `$E7E3` |
| `$E03D` `linkStatusSample` | `serviceCarrierAndSuspendRequest` only | `$1BAB` (bit 6); inside the module `$E61F` |
`$E03B` is **not** carrier-derived. It only says "`$E003` X=0 has been called and the UART has been
opened". Do not make it depend on DCD.
Bit meanings, which must not change:
* `$E03C` bit 6 = carrier present (debounced). `pollMenuSession` `$56B1` tears the whole session down
and jumps to `disconnectFromOpponent` `$1C17` when it is clear; `waitForCarrier` `$1BB6` spins until
it is set; the VOICE PAUSE handler `$65BC` uses it to decide whether to warn the opponent first.
* `$E03C` bit 7 = data has been flowing. `hangUpModemSetState` `$1B4B` waits (up to `$26` ticks of
`zp_7F`) for it to go clear after telling the player to pick up the handset.
* `$E03D` bit 6 = the *undebounced* carrier bit. `waitForCarrier` `$1BAB` uses it to step a countdown
that nothing ever tests, so only bit 6's value matters, not its timing.
### Reading DCD on the 6551 instead
Status register (`base+1`) bit 6 is the DCD line and **it is active low**, so:
```
lda ACIA_STATUS ; base+1
and #$40 ; bit 6: DCD line
; Z=1 (bit clear) means carrier present
```
Two SwiftLink-specific facts, both from CMD's own SwiftLink-232 Application Notes v1.1
(<http://csbruce.com/cbm/ftp/reference/swiftlink.txt>), sections 3.2 and 4:
1. **DCD and DSR are swapped at the ACIA on the SwiftLink.** CMD deliberately exchanged the two
signals so the 6551's receiver (which the chip gates on its own /DCD pin) stays enabled at all
times while the user still gets to see the modem's DCD. The result is that **status bit 6 reflects
the modem's DCD line and bit 5 reflects DSR**, which is the *opposite* of the plain 6551 data
sheet and of the register summary in this project's task brief. Use bit 6.
2. **An unwired DCD reads as carrier present.** There are pull-up resistors on DCD, DSR and CTS, so
"if you happen to use a cable that is missing the DCD line, the pull-up resistor will pull the line
active, so that bit #6 in the status register would be cleared". For a null-modem cable that
carries only TxD, RxD and ground this is exactly the behaviour the game wants: permanent carrier,
no need for the C= + C override at all.
Also worth knowing: a state change on DCD or DSR raises an ACIA interrupt in its own right, so the
NMI handler must be prepared for an NMI in which neither RDRF nor TDRE is set. Reading the status
register clears the interrupt flag.
**Caution about dummy reads.** On the 6502, `STA abs,Y` and `LDA abs,X` perform a read at the
un-carried address whenever the index crosses a page. `loadBaudParameters` `$E353` uses
`sta $DF59,y` with Y = `$FD..$FF` to reach `$E056-$E058`; the un-carried read lands on `$DF56`. If
the cartridge is strapped to `$DF00` and mirrors its four registers through the page, `$DF56` aliases
the command register (`$56 & 3 = 2`), which is harmless - but the same trick landing on `$DF55` would
alias the *status* register, and reading status clears the pending interrupt flag. Avoid
absolute-indexed addressing whose un-carried address can fall inside the ACIA's page.
## 3. The modem state machine ($E756) and connection phases ($E040)
### Call path
Raster IRQ `$1129` -> `$E000` X=0 -> `commRequestDispatch` `$E111` -> `serviceCommTick` `$E12F`.
`serviceCommTick` takes the re-entrancy lock `serviceLock` `$E0A7` (which rests at 1), always calls
`runModemStateMachine` `$E756`, and only when `connectionPhase` `$E040` >= 3 **and** `$E03B` is
non-zero does it also run `receivePacketFsm` `$E14D` and `sendPacketFsm` `$E1DD`.
`runModemStateMachine` falls through into `sendNextModemCommandChar` `$E7CA` and from there into
`runLinkStateMachine` `$E7F7`, so one call walks the whole chain.
### The four phases
| `$E040` | Name | What runs | Border (see below) |
|---|---|---|---|
| 0 | idle / link down | `$E765` sees phase 0 with no restart request and returns immediately | black / yellow |
| 1 | modem command + terminal mode | `serviceModemInput` `$E799` and `sendNextModemCommandChar` `$E7CA` shuttle characters between the modem and the two host rings | light blue / blue |
| 2 | byte sync | `beginByteSyncPhase` `$E805` / `continueByteSync` `$E828` | light red / red |
| 3 | packet protocol | `runPacketPhase` `$E858`, plus the packet FSMs above | light green / green |
Phase 0 is entered by `stopCommNmi` writing `$FF` into `uartRestartRequest` `$E042`, which
`$E75D-$E762` consumes by storing 0 into `$E040`. A request of `$01` (from `restartUart`) means
"re-evaluate but keep the phase".
### How phase 1 is entered and left
```
E77B lda #$01
E77D cmp $E040
E780 beq $E799 ; already in terminal mode
E782 ldx $E03F ; modemReplyTimer
E785 bne $E791 ; a modem dialogue is under way -> enter/stay in terminal mode
E787 bit $E03C ; debounced carrier byte
E78A bvs $E7F7 ; V = bit 6 = carrier: skip the modem dialogue entirely
E78C jsr $E0C3 ; popHostOutRing - no carrier: discard everything queued for the opponent
E78F bcc $E78C
E791 jsr $E534 ; flushUartTxRing
E794 lda #$01
E796 sta $E040 ; phase 1
```
So terminal mode is entered when **either** an AT command is in flight (`$E03F` non-zero) **or** there
is no carrier. `$E78A` is the important branch: with carrier present and no AT string pending, the
module jumps straight to `runLinkStateMachine` and never visits phase 1.
Leaving phase 1 goes through `modemReplyTimer` `$E03F`:
* `queueModemCommandString` `$E393` sets it to `$FF` (negative = "a command string is going out").
* `sendNextModemCommandChar` arms it with `$79` = 121 frames (~2.0 s NTSC) when it transmits the CR
that terminates an AT command (`$E7D5-$E7DE`).
* `$E7E3-$E7E9`: while `$E03C` bit 6 is clear the timer is pinned at 1, so it fires on the first tick
after carrier appears.
* When it reaches 0 the module fabricates a CR into the host input ring (`$E7F2/$E7F4`) so the chat
line unblocks, and falls straight into `runLinkStateMachine`.
`runLinkStateMachine` `$E7F7` then dispatches: any non-zero `linkErrorCount` `$E047` restarts byte
sync whatever the phase; phase 2 continues the sync at `$E828`; phase 3 or above runs the packet
protocol; phases 0 and 1 fall through into a fresh `beginByteSyncPhase`.
### Where the Hayes strings live and how they go out
Three canned strings sit in page `$EB`, **stored back to front**, because
`queueModemCommandString` `$E393` copies them forwards into `hostOutRing+1` (`modemCommandChars`
`$E092`) and then sets the read index to the character count, and `popHostOutRing` walks that index
downwards.
| Address | Emitted as | Queued by |
|---|---|---|
| `$EBD6` `modemHangUpString` | delay `$A0`, `+`, `+`, `+`, delay `$A0`, CR, delay `$C0`, `A`, `T`, `H`, `0`, CR | `hangUpModem` `$E390`, via `hangUpStringPtrLo` `$EBFD` = `$D6` |
| `$EBE3` `modemAnswerInitString` | delay `$C0`, `ATQ0V1X1A`, CR | `openCommLink` `$E2E3` when `$E011` = 0 |
| `$EBF0` `modemDialInitString` | delay `$C0`, `ATQ0V1X1D`, CR | `openCommLink` `$E2E3` when `$E011` = 1 |
`modemInitStringPtrLoTable` `$EBFE` holds the two low bytes `$E3,$F0`; the high byte `$EB` is the
un-patched half of the self-modifying `LDA $EB00,x` at `$E3A1`.
A queued byte with bit 7 set is never transmitted: `$E7D0` parks it in `modemDelayCounter` `$EB01` as
a negative frame count, which `tickGuardDelay` `$E7C0` counts back up to zero. That is how the Hayes
`+++` guard times are produced (`$A0` = 96 frames, `$C0` = 64 frames).
Two things worth noticing about the dial string: it is `ATD` with **no telephone number**, and the
game's prompts ("GET OPPONENT ON PHONE AND...", "PRESS A OR O AND SET MODEM.", "PRESS SPACE, WAIT,
HANGUP PHONE.") make it clear that the players are expected to establish the voice call by hand and
then hand the line to the modems. Modem Wars never dials anybody. So a direct-cable build loses
nothing by not dialling.
`$E048` `modemOptionFlags` (`$CC` on disk) gates all of this:
* bit 7 = "a Hayes modem is attached". `queueModemCommandString` `$E393` tests it first and returns
`A = $FF` without doing anything when it is clear - **no AT string is ever queued and `$E03F` is
never set**. `serviceModemInput` `$E79E` also discards everything received in phase 1 when it is
clear.
* bit 6 = "watch the verbose result codes". `checkConnectSpeed` `$E7A7` sniffs for a CR preceded by
`'0'` (the last digit of `CONNECT 1200`; a plain `CONNECT` ends in `T` and is ignored) and switches
to the second baud entry.
`$E048` is at `$E048`, one byte past the end of the frozen block `$E01D-$E047`, so the SwiftLink build
is free to change it.
### What a direct null-modem connection has to skip
Set `$E048` bit 7 clear (and bit 6 clear - there is no result code to sniff). Then:
* `openCommLink` `$E2E1-$E2E3` still calls `queueModemCommandString`, but it returns immediately, so
`$E03F` `modemReplyTimer` stays 0 and `hostOutCount` `$E02E` stays 0.
* Because `$E02E` stays 0, the chat editor at `$1357` opens straight away instead of waiting for an
AT string to drain.
* On the first tick after `openCommLink`, `$E782` finds `$E03F` = 0 and `$E787` finds carrier, so
`$E78A` branches to `runLinkStateMachine` and the module goes 0 -> 2 -> 3 without ever entering
phase 1. Nothing needs to be cut out of the state machine; it already has the path.
* `hangUpModem` `$E38D` still calls `dropDtrLine` `$E574` and then `queueModemCommandString`, which
again does nothing. On the ACIA the equivalent of `dropDtrLine` is clearing command bit 0, which
also disables the receiver and all interrupts - that is the correct behaviour for "hang up", and
`commLinkControlDispatch` `$E2A2-$E2A9` immediately follows it with `stopCommNmi`. Note `$E2A5`
spins on `modemReplyTimer` `$E03F` until `LSR` leaves zero, i.e. until it is 0 or 1; with no modem
configured `$E03F` is already 0, so that loop exits at once.
* Carrier: with no DCD wire the SwiftLink's pull-up reports carrier permanently, so `$E03C` bit 6
settles set after the 240-frame debounce and stays there. The C= + C override is then redundant,
but it costs nothing to leave it working for people whose cable does carry a real DCD.
The one thing a null-modem build genuinely must handle differently is **who starts talking first**.
The byte-sync handshake at `$E805` is symmetric (both ends send `$00` until they see a `$00`, then
answer `$FF` and wait for the peer's `$FF`, restarting after ten fruitless rounds), so answer and
originate are interchangeable at that level. `$E011` `isOriginateMode` still matters, though: the
game asks the ANSWER/ORIGINATE question at `$0CBD` and stores the result at `$0CDE`, and the stock
module uses it to pick the AT string (`$E2DD`), to index `userPortIdleTable` `$E04F` (both entries are
`$26`, so it makes no difference) and to index `serialShiftPatternTable` `$E051` together with
`baudIndex`. A SwiftLink build should keep reading `$E011` if it wants the two ends to disagree about
anything (for example, to pick different bit patterns), but nothing above the UART requires it.
### Border-colour indicator (useful while bringing the new UART up)
`readKeyAndHandleModemHotkeys` `$E2F0-$E30E` paints `VIC_BORDER` from `linkStatusBorderTable` `$E05F`
using `index = connectionPhase*2 + carrier`. Each byte is `colour*2 + always-paint`:
| Index | Byte | Colour | Painted |
|---|---|---|---|
| 0 (phase 0, no carrier) | `$00` | black | only while C= is held |
| 1 (phase 0, carrier) | `$0E` | yellow | only while C= is held |
| 2 (phase 1, no carrier) | `$1D` | light blue | always |
| 3 (phase 1, carrier) | `$0D` | blue | always |
| 4 (phase 2, no carrier) | `$14` | light red | only while C= is held |
| 5 (phase 2, carrier) | `$04` | red | only while C= is held |
| 6 (phase 3, no carrier) | `$1A` | light green | only while C= is held |
| 7 (phase 3, carrier) | `$0A` | green | only while C= is held |
Hold the Commodore key and the border tells you which phase the link is in. Note the whole hot-key
path (and therefore this indicator) is skipped while `inputLockoutTimer` `$0B7D` is non-zero, because
the raster IRQ only calls `pollKeyboardEvent` `$0E56` at `$11E7` when it is 0.
## 4. Baud selection
### The stock table and what reads it
`baudParameterTable` `$E059`, two 3-byte entries, indexed by `baudIndex` `$E055` (0 or 3):
```
E059 $50 $0D $01 ; entry 0: bit period $0D50 = 3408 cycles = 300.1 baud, pacing 1
E05C $53 $03 $02 ; entry 1: bit period $0353 = 851 cycles = 1201.8 baud, pacing 2
```
(3408 and 851 cycles against the NTSC 1022727 Hz system clock.)
`loadBaudParameters` `$E353` copies three bytes from `baudParameterTable+X` into
`bitPeriodLo` `$E056`, `bitPeriodHi` `$E057` and `txPaceReload` `$E058`, using
`ldy #$FD` / `sta $DF59,y` so one register both counts and indexes (`$DF59 + $FD = $E056`). It
returns `A = $FF`, which is what the hot-key path needs in order to swallow the key.
Three call sites, all inside the module:
| Caller | X | Why |
|---|---|---|
| `openCommLink` `$E2C9` | `baudIndex` `$E055` | cold start |
| C= + 3 / C= + 1 hot keys, falling through `$E350` | 0 / 3 | player choice |
| `switchTo1200Baud` `$E7B2` | 3 (loaded at `$E7B0`) | `CONNECT 1200` was seen on the line |
`$E056/$E057` are consumed by `setBitPeriod` `$E657` (which programs CIA2 timer A or timer B) and by
the half-bit start-bit delay at `$E6E2`; `$E058` reloads `txPaceCounter` `$E044` at `$E644`. All
three are private to the UART layer, so the SwiftLink build may redefine them - for example as
{control-register byte, spare, spare}, or a wider table with more entries.
**The game never writes `$E055`, `$E056`, `$E057` or `$E058`, and never reads them.** There is no
saved baud preference anywhere: `baudIndex` is reset to its disk value (`$00` = 300 baud) every time
the module is reloaded from track 34, and `clearLinkVars` does not touch it because it lies outside
`$E039-$E047`. The only thing the game stores about the link setup is `$E011` `isOriginateMode`,
written at `$0CDE` from the A/O prompt.
`$E051` `serialShiftPatternTable` (`$27,$2F,$3F,$37`, indexed by `isOriginateMode EOR baudIndex`) and
`$E04F` `userPortIdleTable` are user-port artefacts with no SwiftLink equivalent; the shift-register
write at `$E559` goes to CIA2 SP2 (user-port pin 7), which is not part of the standard RS-232 wiring
and whose purpose is not established. Both tables are outside the frozen region and can be reused.
### The Commodore-key hot keys already in use
`readKeyAndHandleModemHotkeys` `$E2EC` is jump-table entry `$E015` and is the game's **only** keyboard
read: `pollKeyboardEvent` starts at `$0E56` with a `JSR $E015`, and the raster IRQ calls it at `$11E7`
once a frame.
It scans through the hook at `$E012`, whose operand the game patches to `scanKeyboard` `$0DB7` at
`$0F4B/$0F50`.
Key codes are the game's own: from `keyMatrixCodeTable` `$0D67`, an ordinary key returns its ASCII
code with bit 7 set (`'A'` = `$C1`, `'3'` = `$B3`), the four function keys return `$00-$03`, control
keys return their ASCII control code with bit 7 set (RETURN = `$8D`), and `$FF` means no key. X
returns `$80` when the Commodore key is held.
Gates before any hot key is considered:
* `$E310` `BPL` - codes below `$80` (the function keys) are handed straight to the game and can never
be hot keys.
* `$E312-$E319` - a code equal to `lastHotkeyCode` `$E046` with the Commodore key still down is
swallowed, which is the auto-repeat filter.
* `$E31F` - the Commodore key must be held (X = `$80`).
The complete list, in the order the code tests them:
| Code | Key | Address | Action |
|---|---|---|---|
| `$C8` | C= + H | `$E323` | `EOR` `dtrToggleMask` `$E04C` = `$24` into `CIA2_PRB`: toggle PB2 (DTR) and PB5 |
| `$C3` | C= + C | `$E332` | `EOR` `$E04B` = `$40` into `carrierOverrideFlags` `$E04A`: pretend carrier is present |
| `$B3` | C= + 3 | `$E342` | `baudIndex` `$E055` = 0, then `loadBaudParameters` with X=0: 300 baud |
| `$B1` | C= + 1 | `$E34A` | `baudIndex` `$E055` = 3, then `loadBaudParameters` with X=3: 1200 baud |
| `$8D` | C= + RETURN | `$E362` | `DEC skipModemInitString` `$E041` (0 -> `$FF`), `openCommLink` `$E2C1`, `modemReplyTimer` `$E03F` = `$8D`; returns the RETURN code to the game with X cleared, so this key is **not** swallowed |
| `$D0` | C= + P | `$E374` | `hangUpModem` `$E38D` (drop DTR, queue `+++`/`ATH0`); its tail returns `$FF` |
| `$C1` | C= + A | `$E37A` | `isOriginateMode` `$E011` = 0, then `configureUserPortLines` `$E540` |
| `$CF` | C= + O | `$E37E` | `isOriginateMode` `$E011` = 1, then `configureUserPortLines` `$E540` |
Every one of them except C= + RETURN returns `A = $FF` so the game never sees the keystroke. So does
**every other** Commodore-key combination: the fall-through at `$E38A` returns `$FF` unconditionally.
Adding new hot keys therefore cannot break anything the game currently does with the keyboard - those
codes are already being eaten - as long as the eight codes above keep their meanings.
Free and sensible for new baud keys: C= + 2 (`$B2`), C= + 4 (`$B4`), C= + 9 (`$B9`), C= + 0 (`$B0`),
C= + 6 (`$B6`), C= + 8 (`$B8`). Note that C= + 3 and C= + 1 are already spent on 300 and 1200, so a
"digit = speed" scheme has to work around them; mapping C= + 2 -> 2400, C= + 4 -> 4800, C= + 9 ->
9600, C= + 0 -> 19200 and C= + 8 -> 38400 collides with nothing. Avoid `$FE` and `$FF` (the modifier
and no-key sentinels) and remember that `$B3`/`$B1` must keep working if the module is to stay usable
with a real 300/1200 modem.
### 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:
1. CMD, *SwiftLink-232 Application Notes v1.1*, section 3.4 and the control-register figure
(<http://csbruce.com/cbm/ftp/reference/swiftlink.txt>). "Note that our cartridge uses a
double-speed crystal, so values given on the data sheet are doubled ... (the minimum speed is 100
bps and the maximum speed is 38,400 bps)", followed by the full table below.
2. The cc65 SwiftLink driver `libsrc/c64/ser/c64-swlink.s`
(<https://github.com/cc65/cc65/blob/master/libsrc/c64/ser/c64-swlink.s>), whose `BaudTable` maps
300 -> `$05`, 600 -> `$06`, 1200 -> `$07`, 2400 -> `$08`, 3600 -> `$09`, 4800 -> `$0A`,
7200 -> `$0B`, 9600 -> `$0C`, 19200 -> `$0E`, 38400 -> `$0F`, and marks 50/75/110/134.5/1800 as
unavailable - exactly what a doubled table predicts (1800 is unreachable because the nominal 1800
entry now yields 3600).
Control register (`base+3`) = `$10` (bit 4 = internal baud-rate generator, bits 6-5 = `00` = 8 data
bits, bit 7 = 0 = one stop bit) OR the rate bits:
| Bits 3-0 | Control byte | SwiftLink rate | Nominal 6551 rate |
|---|---|---|---|
| `$00` | `$10` | 16x external clock | 16x external clock |
| `$01` | `$11` | 100 | 50 |
| `$02` | `$12` | 150 | 75 |
| `$03` | `$13` | 219.84 | 109.92 |
| `$04` | `$14` | 269.16 | 134.58 |
| `$05` | `$15` | **300** | 150 |
| `$06` | `$16` | 600 | 300 |
| `$07` | `$17` | **1200** | 600 |
| `$08` | `$18` | **2400** | 1200 |
| `$09` | `$19` | 3600 | 1800 |
| `$0A` | `$1A` | **4800** | 2400 |
| `$0B` | `$1B` | 7200 | 3600 |
| `$0C` | `$1C` | **9600** | 4800 |
| `$0D` | `$1D` | 14400 | 7200 |
| `$0E` | `$1E` | **19200** | 9600 |
| `$0F` | `$1F` | **38400** | 19200 |
To stay wire compatible with the stock driver, the SwiftLink build must be able to produce 300
(`$15`) and 1200 (`$17`). Those are the only two speeds the stock module can talk at, so a stock
machine and a SwiftLink machine can only meet there.
Bear in mind that the game's *throughput* ceiling is not the line speed. The frame layer starts at
most one thing per raster IRQ (`$E860` refuses when `uartPendingCount` >= 2) and the packet layer
exchanges one packet per lock-step turn, so past a few thousand baud the win is latency and
retransmit cost, not bandwidth.
## 5. `$E030` - the build id
`commBuildId` `$E030` is one byte inside the frozen block `$E01D-$E047`.
| Module | Value | Source |
|---|---|---|
| `game/modemDriverE000` (track 18 s7 + track 34) | `$00` | `modemDriverE000.s` `$E030`, and byte `$030` of the rebuilt 4096-byte image |
| `game/trainerAiE000` (track 35) | `$FF` | `trainerAiE000.s` `$E030` |
Only bit 7 is ever tested. Nothing in the module writes it; it is a constant baked into the sector
image, and it survives every `clearInlineVarBlock` because none of the cleared blocks covers `$E030`
(`clearPacketVars` stops at `$E02F`, `clearLinkVars` starts at `$E039`).
The five read sites in `mainProgram0800.s`:
| Address | Routine | What the game does |
|---|---|---|
| `$0AEA` | `startGameFromSetup` | `EOR isSoloTrainer` (`$0BA5`), `AND #$80`: if bit 7 of the wanted opponent and the resident module disagree, `$0AF1/$0AF4` load the other module off disk |
| `$0B2C` | `startGameFromSetup` | `LDA $E030` / `STA $0BA5`: the resident module's id is written **back over** the wanted flag, so `$0BA5` afterwards always describes what is actually in memory |
| `$1A9F` | `miscTabAction` (ABORT GAME) | negative: skip the `$8C`/reason-0 "I am leaving" handshake, because the trainer discards everything sent to it |
| `$1AFA` | `endGameToMainMenu` | negative: force `isLinkActive` `$E03B` to 0 and preselect main-menu item 1 (PRACTICE WITH SOLO TRAINER) instead of item 0 |
| `$1B3D` | `hangUpModemSetState` | negative: skip the "PICK UP PHONE THEN PRESS SPACE." prompt (message `$17`) and the carrier-quiet wait at `$1B4B`, and go straight to `$E003` X=3 |
**The SwiftLink module must keep `$E030 = $00`.** It is the modem build; a `$FF` there would make the
game believe the solo trainer is resident, which would (a) make `$0AEA` reload track 34 over the top
of the SwiftLink module every time a modem game starts, (b) suppress the hang-up handshake and the
end-of-game link teardown, and (c) rewrite `$0BA5` to `$FF`, turning the game into a solo game.
`checkAbi.py` enforces this as part of the `$E01D-$E047` frozen region, but it is worth stating
explicitly because it is the one byte in that region that carries meaning rather than state.
Two related bytes in the same frozen block, for completeness:
* `$E02F` `peerCommBuildId` - the *opponent's* `$E030`, stored by `exchangePlayerNamesAndSettings`
at `$EDAD` during the start-of-session handshake. Both ends of a modem game therefore report `$00`
to each other; a SwiftLink machine and a stock machine will exchange identical ids, which is what
we want since the wire protocol is unchanged.
* `$E045-$E047` hold `$44 $54 $48` ("DTH") on disk - leftovers from the sector image, because this
page comes from track 18 sector 7, outside the encrypted area. They are inside the frozen region,
so the SwiftLink image must reproduce those exact junk bytes even though `clearLinkVars` `$E034`
zeroes them the moment the link is closed.
## 6. Checklist for the replacement UART layer
Contracts, in the order they will bite:
1. `$E030` stays `$00`; `$E01D-$E047` and `$E000-$E017` come out byte-identical, junk bytes included.
2. `$E039`/`$E03A` suspend handshake served once per frame from the `$E00C` path, with `$80`/`$00`
bit-7 semantics; ACIA interrupts genuinely off between them; CIA2 never touched.
3. `$E031/$E032` still chained on a foreign NMI.
4. `$E03D` publishes `$80`/`$F8`-shaped samples (bit 6 = carrier, bit 7 = data flowing within ~128
frames), driven from ACIA status bit 6 with **carrier = bit clear**; `$E045` zeroed from the
receive path; the 240-frame debounce at `$E3CB` left exactly as it is.
5. `$E03B` set by `openCommLink`, not by carrier.
6. `$E0A5` `uartPendingCount` accurate at all times; `$E044` writable by `$E820`.
7. `queueByteForTransmit` `$E73C`, `flushUartTxRing` `$E534`, `configureUserPortLines` `$E540`,
`dropDtrLine` `$E574`, `installCommNmiVector` `$E57B`, `restartUart` `$E586`, `stopCommNmi`
`$E5C3` and `serviceCarrierAndSuspendRequest` `$E5D6` keep their addresses.
8. `$E048` bit 7 clear for a cable-only build (no AT strings, no result-code sniffing, phase 1 never
entered); leave the option so a real modem can still be driven.
9. `loadBaudParameters` `$E353` keeps its three-byte stride and its `A = $FF` return, or all four of
its call sites (`$E2C9`, `$E350` fall-through, `$E7B2`) change together.
10. New hot keys only in the free codes; `$C8 $C3 $B3 $B1 $8D $D0 $C1 $CF` keep their present
meanings, and C= + RETURN keeps returning the key to the game rather than swallowing it.
## 7. Open questions
* **Which SwiftLink clones swap DCD and DSR?** CMD's own cartridge does (section 2), and the
Turbo232 is documented as SwiftLink compatible, but the modern reimplementations (GLINK232 and
friends) advertise "modem control" as an option and do not all say which way round they wire it.
A build that only ever sees a null-modem cable does not care, because both bits read active; a
build that wants real DCD should probably offer a bit in `$E04A` to select which status bit to
look at, since `$E04A` is already used as a polarity/override mask by `$E5F1`.
* **Base address.** `$DE00` is the default, `$DF00` the strap option; CMD's own notes recommend that
software cope with both (probe by writing the control register and reading it back). A run-time
probe costs a few bytes and removes a support headache, and there is plenty of room in the freed
UART region.
* **Status-register bit 7 at high speed.** CMD warns that bit 7 (interrupt occurred) is unreliable
at 9600 bps and above, and their own sample NMI handler ignores it and tests RDRF/TDRE/DCD/DSR
directly. Worth following.
* **CTS.** The 6551 stops transmitting on its own when CTS goes inactive, and clears TDRE while it
does, with no way to see CTS from any register. On a null-modem cable with RTS/CTS crossed, that
gives a free flow-control mechanism during disk loads: raising RTS on the suspending machine (both
transmit-control bits clear) throttles the peer's transmitter automatically. Whether that is worth
using, or whether it is better to let the ARQ layer clean up as it does today, is a design decision
the driver work will have to make.