# 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 (), 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 (). "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` (), 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.