# Modem Wars over a SwiftLink

An alternate opponent module for *Modem Wars* (C64, Electronic Arts / Ozark Softscape, 1988) that
talks to a **SwiftLink cartridge** - a 6551 ACIA on the expansion port - instead of the shipped
module's bit-banged user-port UART.  It is a drop-in replacement for the stock modem driver: same
4096 bytes at `$E000-$EFFF`, same jump table, same shared variables, same wire protocol.

**It does not impose a line rate.**  The driver comes up in *adopt mode*: it reads the 6551's control
register before it touches anything and uses the rate, stop bits and clock source the hardware is
already configured for, so a C64 Ultimate or a SwiftLink that has been set up for something in
particular is played at its own setting.  Eight data bits are the one thing it insists on, because
the frame layer above is not 7-bit clean.  Seven fixed rates from 300 to 38400 are still one hot key
away for anyone who wants to pick one, and `C= + U` goes back to the port's own settings.

**Why it works, in one sentence:** the shipped module is layered, and only the bottom layer is
hardware specific, so replacing the UART and nothing else leaves the packet layer, the ARQ frame
layer, the ring buffers and the Hayes/link state machine bit-for-bit as Ozark Softscape wrote them.

```
  packet / chat layer    $E111-$E2FF   game-facing packets, host rings, exchange flags   unchanged
  modem + link state     $E756-$E8xx   Hayes control, connection phases, carrier         unchanged
  frame / ARQ layer      $E909-$EAB2   length + control + sequence + checksum, retries    unchanged
  ring buffers           $E40F-$E533   four descending circular buffers                  unchanged
  UART layer             the rest      CIA2 timers, NMI bit-banging, user port        >> REPLACED <<
```

474 of the module's 4096 bytes changed.  Everything else, including all three regions the rest of
the game reaches into, is byte-identical to the original.

---

## 1. Hardware

**What it needs**

* A Commodore 64 (or 128 in 64 mode) and the original *Modem Wars* disk.
* A **SwiftLink-232** or compatible cartridge (Turbo232 and the modern GLINK232-style rebuilds
  present the same 6551 at the same addresses).  Base `$DE00` is the default; the module probes
  `$DE00` first and falls back to the `$DF00` strap option automatically, so either jumper setting
  works with the same disk.
* The cartridge's NMI must be wired to the expansion port `/NMI` line, which is how a SwiftLink is
  normally strapped.  The module installs its own handler at `$FFFA/$FFFB` and chains anything that
  is not its own interrupt, so RESTORE still works.
* At the far end: a modem, or a null-modem cable to a second C64 with a SwiftLink.

**What it does NOT need**

* **No user-port RS-232 cable or interface.**  The user port is not touched at all.
* **No CIA2 timers.**  The stock driver latched CIA2 timer A and timer B to a bit period and
  bit-banged every bit out of `CIA2_PRA` PA2 from the NMI.  None of that is left.  The only CIA2
  code in the whole module is `silenceCia2` `$E56F`, which pins `CIA2_ICR` at `$7F` and reads it
  back so CIA2 lets go of `/NMI` for good.
* **No baud-rate calibration.**  The line rate is one read of the ACIA control register - to find out
  what the port is already set to - and one write of it back.

**Caveat worth knowing before you buy a board.**  Some modern SwiftLink rebuilds use a WDC
**W65C51N**, whose TDRE status bit reads as permanently set.  This driver is interrupt driven and
never polls TDRE in a loop, so it does not hang, but on such a board the transmit interrupt fires
continuously and characters would be overrun; those boards need a timed delay per character instead.
A genuine 6551 - and VICE's emulation of one - behaves.

---

## 2. Building and installing

Requires `ca65`/`ld65` from the cc65 tool chain and Python 3.  From the repository root:

```
./swiftlink/build.sh                       # -> Modem_Wars_SwiftLink.d64
./swiftlink/build.sh /path/to/other.d64    # or name your own output
```

`build.sh` does three things:

1. assembles `swiftlinkDriverE000.s` with `ca65` and links it through `link.cfg` into a flat
   4096-byte image at `$E000` (`swiftlink/build/swiftlinkDriverE000.bin`);
2. refuses to continue unless that image is exactly 4096 bytes;
3. runs `injectDriver.py`, which copies `Modem_Wars_1988_Electronic_Arts_b4.d64` and writes the
   module into it, re-applying the EA sector cipher on the way.

**The disk it produces** is a full 35-track, 174848-byte `.d64` that boots and plays exactly like
the original - same loader, same title screen, same copy-protection map question, same solo trainer.
It differs from the stock image in **six sectors: track 18 sector 7, and track 34 sectors 3, 4, 5, 6
and 7** - module pages `$E000`, `$E300`, `$E400`, `$E500`, `$E600` and `$E700`, the only pages any
changed byte lands in.  Track 34 sectors 1, 2 and 8-15 are byte-identical to the original.  (Page
`$E000` used to be identical too; it stopped being so when the transmitter fix took over the dead
stock-UART constants at `$E04C-$E05E`, and page `$E400` joined the list when the probe hardening took
over the dead `ignoreLinkError` stub at `$E408`.  The per-page counts are 18, 43, 12, 111, 236 and 54
changed bytes, which add up to the 474 `checkAbi.py` reports, because the EA sector cipher is a
keystream XOR that depends only on the track and sector.)

The module is only fetched when the player picks COMPETE WITH MODEM OPPONENT.  A solo game loads the
trainer from track 35, which this build does not touch.

### Checking the ABI

`checkAbi.py` proves that nothing the rest of the game depends on has moved:

```
python3 tools/build.py                     # writes disassembly/build/*.orig.bin from the disk
python3 swiftlink/checkAbi.py swiftlink/build/swiftlinkDriverE000.bin \
                              disassembly/build/game_modemDriverE000.orig.bin
```

It prints every changed range and then verifies the three frozen regions and the twenty-two entry
points.  Current output ends with:

```
bytes changed: 474 of 4096, in 27 region(s)
...
ABI CHECK PASSED: every address the rest of the game depends on is unchanged
```

---

## 3. Port settings: adopt mode, hot keys and direct connection

**The point of this section in one sentence: the driver runs at whatever your port is configured
for.**  It does not impose a line rate, it does not impose stop bits, and it does not impose a clock
source.  Two things it does insist on, and both are forced by the protocol above it rather than
chosen: **eight data bits**, because the frame layer is not 7-bit clean, and a usable clock, because
a SwiftLink has no external one to run from.  Everything else is copied out of the chip as it stands.
If you would rather name a rate, seven of them are one hot key away, and `C= + U` hands control back
to the port.

### Adopt mode: the port's own settings

The last entry of `baudEntryTable` `$E710` (the table below), index 21, has no rate of its own.
`aciaProbeAdopted` `$E655` reads the 6551's control register on the cold start - before the page probe
overwrites it, which is the only moment that value still exists - and parks it in that entry,
corrected only where it has to be:

* **the rate, the stop bits and the receiver clock source are copied through untouched;**
* **the word length is forced to eight data bits.**  See the next heading; this one is not
  negotiable.
* **a setting that cannot work is replaced.**  `control & $1F` below `$11` means bit 4 = 0 (an
  external receiver clock, which a SwiftLink does not have), or rate bits `%0000` (an external
  transmit clock), or both - which is exactly what a chip that nobody has configured reads as, since
  reset clears the register to `$00`.  The fallback is `$15`: 300 baud, 8N1, internal generator.  It
  is the rate the stock driver always came up in, one of the only two a stock user-port peer can
  produce, and the game generates about 30 bytes a second, which is precisely what 300 baud carries.

Adopt mode is the **disk default**: `baudIndex` `$E055` ships as 21, so a link opened without
pressing anything runs at the port's own settings.  `C= + U` returns to it at any time.

Measured, two machines, with the control register pre-set from the monitor exactly as a configured
cartridge would have left it (`portCompatReport.md` section 5, `testReport.md` section 11):

| What the port was set to before the game touched it | What `$DE03` held after the link opened | The link |
|---|---|---|
| `$98` - 2400 baud, 8 data bits, **two stop bits** | **`$98`** on both machines, adopted whole | healthy, 75.2 s, 9.0 B/s each way, 0 of 148 seconds carrying nothing |
| `$B8` - 2400 baud, **seven data bits**, two stop bits | **`$98`** on both machines - only the word length was corrected | healthy, 75.2 s, 9.0 B/s each way, 0 of 148 empty |
| `$00` - a chip nobody has configured | **`$15`** on both machines - the 300-baud fallback | works, 75.2 s, 10.2 B/s each way, 0 of 148 empty, and the wire really runs at 300 |

The old driver wrote `$15` into all three of those cases without looking at what was there.

**What adopt mode does not cover: parity.**  Parity lives in the 6551's *command* register, not its
control register, and the driver rewrites that register from constants (`$09`, `$0B`, `$05`) every
time it arms or disarms the transmitter or suspends the receiver for a disk load - DTR, RTS and both
interrupt enables share the byte, so it cannot be part-written.  All of those constants have the
parity bits clear.  A port configured for parity therefore runs without it.  Both ends of a *Modem
Wars* link run this same driver and clear those bits identically, so the two still agree; what it
would break is a peer that is not this driver and does expect parity, and there is no such peer.
`portCompatReport.md` section 8 has the measurement (command `$69`, even parity, pre-set on both
machines: the link came up and ran, with parity off).

### The one setting that is not adoptable: eight data bits

The frame layer above the UART is **not 7-bit clean**, so eight data bits are mandatory whatever else
the port is set to.  `aciaProbeAdopted` forces bits 6-5 of the control byte to `%00` with a single
`AND #$9F` at `$E665` - the only place in the module that masks a control byte - and leaves every
other bit alone.  Three places in the unchanged protocol need the eighth bit:

* the byte-sync handshake answers `$00` with **`$FF`**, and `$FF` is tested for *exactly*, by
  incrementing it to zero (`INX` / `BNE restartByteSync` at `$E852-$E853`).  On a seven-bit line the
  peer's `$FF` arrives as `$7F`, the test fails, and byte sync restarts for ever: the border stays
  red and the link never turns green;
* the frame layer leads with a `$55` length code and follows it with raw sequence bytes that take any
  value at all;
* `computePacketChecksum` `$E937` is a full eight-bit rotate-and-add (`ASL A` / `ADC #$00` /
  `ADC packetBuffer,x`).

**Parity and stop bits cost the frame layer nothing** - they change how a character is delivered, not
what is in it - which is exactly why the mask touches two bits and no others.  This is a reading of
the source rather than an experiment, and it cannot be an experiment on this rig: VICE's ACIA is byte
level and will not truncate a character to seven bits however the control register is set.  What
*was* measured is what the correction writes: a port pre-set to `$B8` (seven data bits) came up as
`$98`, with the stop bit, the clock source and the rate bits all untouched, and `$5A` (six data bits)
and `$3C` (seven data bits) came up as `$1A` and `$1C`.

### The seven fixed rates, and their hot keys

A SwiftLink clocks its 6551 from a 3.6864 MHz crystal, twice what the chip expects, so every rate in
the data sheet's table comes out **doubled**.  The module's table is at `$E710`: eight three-byte
entries of `{hot-key code, ACIA control byte, spare}`.  For the seven fixed rates the control byte is
`$10` - 8 data bits, one stop bit, internal baud-rate generator - OR the rate bits, so **pressing a
speed hot key selects 8N1 outright** and leaves adopt mode.  The eighth entry is adopt, and its
control byte is written at run time.

| Index | Hot key | Control byte | Rate | Stock user-port peer? | Two machines at true C64 speed, on this build |
|---|---|---|---|---|---|
| 0 | C= + 3 | `$15` | 300 | yes | **healthy**, and it saturates the line: 29.6 B/s, no second carrying nothing, no error of either kind |
| 3 | C= + 1 | `$17` | 1200 | yes | **healthy**; one receive-ring overflow on each machine, both recovered |
| 6 | C= + 2 | `$18` | 2400 | no | **healthy**; no error of either kind |
| 9 | C= + 4 | `$1A` | 4800 | no | **healthy**; no error of either kind |
| 12 | C= + 9 | `$1C` | 9600 | no | **healthy**; no error of either kind |
| 15 | C= + 0 | `$1E` | 19200 | no | **healthy**; no error of either kind |
| 18 | C= + 8 | `$1F` | 38400 | no | **healthy** in two boots, 170 s and 245 s; no error of either kind, and no wedge in seven boots |
| 21 | C= + U | *the port's own* | whatever the hardware is set to | if the hardware is | the default; see above |

That column is `portCompatReport.md` section 3 and `testReport.md` section 11: one boot per rate,
three measured windows each - 75 seconds clean, 75 with the byte-sync tracepoints and 20 with the
error tracepoints - 170 seconds of link time per rate and 1194 seconds over the sweep.  In the whole
sweep there were **two** characters rejected by a receiver and **two** receive-ring overflows, and
they are the same two events: one on each machine, both at 1200 baud, both in that rate's 20-second
instrumented window, both recovered from.  No rate above 1200 produced a receive error at all, and no
emulator log in the sweep contains one of VICE's `data register written although data has not been
sent yet` lines, which the pre-guard build produced for 0.8% of transmitted characters at 4800 rising
to 2.3% at 19200.  (The error counts are from the instrumented windows; the "no second carrying
nothing" figures cover all 334 sampling seconds of each boot.)

**Read that table with its traffic in mind.**  At the point every one of those measurements was taken
the game puts about **nine bytes a second in each direction** on the wire, and it does that at every
rate from 1200 up: the line rate makes no difference to how many ARQ frames the lock-step packet
layer produces.  That is this driver's whole premise stated as a measurement - *300 baud already
carries everything the game generates* - and it is why "which rate is fastest" is the wrong question
to ask of the table.  300 is the one rate that has to work for it, moving 29.6 B/s and three times as
many `$55` frame-lead bytes per window as any faster rate.

**What was different before the re-entrancy guard.**  9600 and 19200 used to overflow the receive ring
8 to 128 times in a 20-second window and spend a visible part of every second re-synchronising, and
38400 wedged the machine four boots out of four, one of them ending in `*** Main CPU: JAM at $0007`.
`wedgeDiagnosis.md` traced all of it to unbounded NMI re-entrancy inside `commNmiHandler` - to the
handler, not to the rate - and the guard described in section 5 bounds the nesting at one frame
whatever the line rate.  `highRateReport.md`'s "must not be selected: 38400" was written from those
four boots and has been overturned; that file carries a note at the top pointing here, and its
measurements are left as they were taken.

**Where the headroom still runs out.**  The raw receive ring at `$E430` holds 20 characters, only the
once-per-frame link tick drains it, and `awaitWholePacket` `$E9D9` will not copy a packet body out
until the whole payload and its check byte are resident - up to 17 bytes - so the real headroom is
about three bytes.  There is **no RTS throttling** anywhere in this driver.  A video frame is 0.5
character times at 300 baud, 4 at 2400, 8 at 4800, 16 at 9600, 32 at 19200 and 64 at 38400
(`highRateReport.md` section 1), so a peer's ARQ frame arriving as a burst starts to outrun the ring
somewhere above 4800, and at 38400 three bursts can land inside one frame.  What the driver does about
an overflow is the part that matters: `linkErrorCount` forces a byte-sync round, the round completes,
and the packet layer carries on.  That path was exercised at 1200 on this build and at 9600 and 19200
on the previous one.  What the guard changes is the *next* step - on the old build a resync flood at
38400 tipped the handler into the runaway that ate page 1, and that link in the chain is gone.

**38400 is the rate with the least margin, and the margin is measured.**  At the traffic this game
produces the handler is entered about eighteen times a second and, in 3252 entries across 90 seconds,
**never nested once**; but half of the gaps between consecutive entries were under 300 cycles, with a
median of **266 - exactly one character time at 38400** - against a handler that costs 175 cycles for
one half and 256-266 for a pass that services both.  The margin is between zero and ninety cycles.  Nothing in this game saturates a
38400 line, so the saturated case was never provoked here; the arithmetic still says a saturated
38400 line costs more CPU than a C64 has, and what that now buys is ring overflows and resyncs rather
than a dead machine.

Note also that a SwiftLink rebuilt with the standard 1.8432 MHz part will run every entry in that
table at half the speed shown.

Indices 0 and 3 keep the meanings the stock `baudParameterTable` gave them, because the module's own
code uses those numbers: the `CONNECT 1200` result-code sniffer at `$E7B0` loads 3.

`baudIndex` `$E055` is **not saved anywhere**.  It comes off the disk as 21 (adopt) every time the
module is loaded, and the game never reads or writes it.  So the working sequence is: pick COMPETE
WITH MODEM OPPONENT, wait for the module to load and the "GET OPPONENT ON PHONE AND PRESS A OR O"
prompt to appear, **then** press a speed hot key if you want to override the port's own setting, then
A or O, then space.  Pressing a speed key also reprograms the ACIA immediately, so it works on a live
link too - but both ends have to agree, whether by adopting the same configuration or by pressing the
same key.

### `$DE00` or `$DF00`: the strap looks after itself

Base `$DE00` is the SwiftLink default; `$DF00` is the usual jumper alternative, taken when something
else already lives at `$DE00`.  **The same disk works either way and there is nothing to set.**  On
the cold start - once, before the module has touched the chip for any other reason -
`aciaDetect` `$E644` calls `aciaProbeAdopted` `$E655` on `$DE00`: it reads that page's control
register, writes `$1E`, reads it back, writes `$15`, reads it back, and if both patterns survive it
latches `$DE` into `aciaPageLatch` `$E5BF` and self-modifies the six accessors to that page.  If the
first pattern does not read back - an empty `$DE00` is open bus - it repeats the whole sequence on
`$DF00` and latches `$DF`.

Three properties of that matter for port compatibility.  The first was measured on the current build
(`portCompatReport.md` section 9.1, `testReport.md` section 11.6); the other two were measured on the
build that introduced the page latch (`hardenReport.md`) and re-checked in the current build's strap
traces, which show one probe sequence per cold start and no mirror access:

* **The fallback reads your chip, not the open bus.**  The `$DF00` pass reads `$DF03` before it writes
  a probe pattern into it, so a cartridge strapped to `$DF00` gets *its own* settings adopted.  The
  byte the failed `$DE00` read returned - `$F6` in the measured run, which is screen data on the
  floating bus - sits in the adopt entry for a few instructions and is then overwritten by the real
  one.
* **The probe runs once per cold start**, not once per link open and once per hot key.  That matters
  when something else is at `$DE00`, which is the usual reason to strap to `$DF00` in the first place:
  the build before the latch wrote `$1E` into that foreign device's register on every link open and
  every speed key, on a live link.  Measured in `hardenReport.md`: eight calls to `loadBaudParameters`
  per machine - two link opens and six landed hot keys - and the probe ran **once**, with the six
  hot-key writes `$17 $18 $1A $1C $1E $15` and no probe pattern in front of any of them.
* **Nothing touches the other page, or the mirrors.**  On a `$DF00` machine the whole of the driver's
  business with `$DE00-$DE03` is over in three accesses on the current build - the adopt read, one
  probe write, one failed read-back - and it never goes back; `hardenReport.md` measured two on the
  build before the adopt read, and zero during a hot-key sweep and a 20-second census of 3281 register
  accesses.  The register mirrors `$DE04-$DEFF` / `$DF04-$DFFF` were never addressed at all, on either
  strap, on either build.  That last one is not cosmetic: one of those mirrors is the data register,
  and reading it eats a received character.

### If you are running this on a C64 Ultimate

The Ultimate's SwiftLink emulation is configured in the Ultimate's own menus, not by the game, and it
can be set to a much faster line than *Modem Wars* was ever written for.  That is the case this
driver was reworked for.

* **The driver takes the setting; it does not make one.**  It comes up in adopt mode and uses the
  control register the device already holds, so whatever the Ultimate is configured for is what the
  game runs at - as long as the device presents that configuration in the control register the way
  VICE and a real SwiftLink do.  If it presents `$00` there (an unconfigured 6551 reads that after
  reset) the driver falls back to 300 baud rather than trying to run on an external clock that is not
  there.  Any speed hot key still overrides it, and `C= + U` goes back.
* **Every rate came up healthy on this build**, 300 through 38400: continuous traffic in both
  directions in every one of 334 sampling seconds per rate, no receive error above 1200 baud, and no
  transmitted character lost.  The 9600 and 19200 "degrades and recovers" warning in the older
  documents, and the "38400 must not be selected" one, both belong to the build before the
  re-entrancy guard.
* **Two stop bits, or seven data bits, or an unconfigured chip** were all handed to the driver with
  two machines linked: `$98` came through whole, `$B8` came through with only the word length
  corrected, and `$00` fell back to 300 baud.  Parity is the one thing that does not survive; see
  above.
* **If the two ends disagree on the rate**, there is no negotiation in the protocol to fix it.  On
  this rig a 38400/300 mismatch did no harm - VICE paces whole bytes and does not garble them, so the
  pair simply ran at the slower end's rate - but on real hardware the slow receiver would raise
  framing and overrun errors on nearly every character, and the symptom is the one in section 4: the
  border stays red, the link never turns green, the game never gets past "OPPONENT PICKING GAME/MAP."
  The fix is the same in both cases: put both ends on the same setting, either by configuring both
  cartridges the same way or by pressing the same hot key on both.
* **None of this has been run on hardware.**  Every measurement in this repository is VICE 3.7.1.  No
  physical SwiftLink, no real cable, and no C64 Ultimate has ever been touched by any of it - and
  adopt mode in particular is only as good as what the device puts in its control register.  Every
  preset in every adopt run was written there from the VICE monitor.

The measurements behind that list are in `portCompatReport.md` (the current build: every rate, adopt
mode on two machines, the eight-bit guard, both straps) and `wedgeDiagnosis.md` (why 38400 used to
take the machine down), with the earlier `highRateReport.md`, `rate1200Report.md`, `realtimeReport.md`,
`strapReport.md` and `hardenReport.md` behind them; `testReport.md` folds all of it together.

### Hot keys

`$E015` is the game's **only** keyboard read, so these work anywhere the game is reading keys
(except while `inputLockoutTimer` `$0B7D` is non-zero, which is when the raster IRQ skips the
keyboard poll entirely).  Every combination except C= + RETURN is swallowed, so the game never sees
the keystroke - that was already true of the stock driver.

| Key | Action |
|---|---|
| C= + 3 / 1 / 2 / 4 / 9 / 0 / 8 | 300 / 1200 / 2400 / 4800 / 9600 / 19200 / 38400 baud - impose a fixed rate **and 8N1**, leaving adopt mode |
| C= + U | adopt mode: use the rate, stop bits and clock source the port is already configured for (the default) |
| C= + A | answer mode, then re-program the ACIA |
| C= + O | originate mode, then re-program the ACIA |
| C= + RETURN | re-open the link without re-sending the AT string (the only key passed on to the game) |
| C= + P | hang up: drop DTR, then queue `+++` / `ATH0` |
| C= + H | toggle DTR |
| C= + C | toggle the carrier override - report carrier without looking at DCD |
| C= (held) | paint the border with the link-status colour |

Two changes in behaviour from the stock driver, both forced by the hardware:

* **C= + H** used to flip CIA2 PB2 and PB5 together.  It now flips bit 0 of the ACIA command
  register.  On a 6551 clearing that bit does not only drop DTR, it also shuts the receiver and
  every ACIA interrupt down - which is exactly right for "hang up", but it means the link stays dead
  until you press C= + H again.  `startNextTxChar` refuses to arm the transmitter while DTR is down,
  so a hang-up stays hung up.
* **C= (held)** still paints the border from `linkStatusBorderTable` `$E05F`, indexed by
  `connectionPhase*2 + carrier`, and that table is unchanged: black/yellow = phase 0, light
  blue/blue = phase 1 (talking to the modem, always painted), light red/red = phase 2 (byte sync),
  light green/green = phase 3 (the packet protocol is running).  Green is what you want.

### Direct connection (null-modem cable, no modem)

This works out of the box and needs no separate build.  DCD, DSR and CTS are pulled up on the
cartridge, so a three-wire null-modem cable with no DCD wire reads as **permanent carrier**, which
is what a direct connection wants.  The sequence is:

1. Both machines: COMPETE WITH MODEM OPPONENT.
2. Both machines: nothing, if both cartridges are configured the same way - adopt mode is the
   default and both ends will come up on their own settings.  Otherwise the same speed hot key on
   both, which also puts both ends on 8N1.
3. One machine A (answer), the other O (originate).  The byte-sync handshake at `$E805` is
   symmetric, so this choice does not decide who talks first - but the game still asks, and `$E011`
   still records it.
4. Both machines: space.

The module still queues the Hayes answer/originate string (`modemOptionFlags` `$E048` ships as
`$CC`, bit 7 = "a modem is attached").  Those nine characters go down the cable and are discarded by
the peer's byte-sync phase, and after roughly two seconds - the 121-frame answer timer at `$E7D5` -
the state machine falls through into byte sync and the link comes up.  That two-second wait is the
entire cost of leaving the Hayes layer switched on.

If you want it truly silent, clear bit 7 (and bit 6, since there is no result code to sniff) of
`modemOptionFlags` at `$E048`.  That byte lies outside the frozen region and is a plain constant in
the source; `queueModemCommandString` `$E393` then returns immediately, `modemReplyTimer` stays 0,
and the module goes phase 0 -> 2 -> 3 without ever entering phase 1.  Nothing else has to change.

C= + C is redundant on a three-wire cable, but it is still there for a cable that does carry a real
DCD from a peer that is not asserting it.

---

## 4. Wire compatibility with the stock driver

**A SwiftLink player and a stock user-port player can play each other, at 300 or at 1200 baud.**

Nothing above the UART changed, so both machines put the same bits on the line: the same `$00`/`$FF`
byte-sync handshake, the same length + control + sequence + checksum frame, the same retransmit
rules, the same packet and chat layers, and the same `$E030` build id (`$00` = "I am the modem
module") exchanged during `exchangePlayerNamesAndSettings`.  Neither end can tell what kind of UART
the other is using, because nothing on the wire says.

**Framing is the one thing to watch now that the driver adopts the port's settings.**  The stock
user-port driver is 8N1 and nothing else; a SwiftLink in adopt mode is 8 data bits and whatever stop
bits and parity the cartridge is configured for.  Eight data bits are guaranteed at both ends - the
driver corrects the word length and nothing else - so a SwiftLink whose port happens to be set to
8N1, which is what an unconfigured one falls back to, is bit-for-bit what the stock driver produces.
A SwiftLink set to two stop bits or to parity will not talk to a stock user-port peer, and the fix is
either to configure the port for 8N1 or to press a speed hot key, which selects 8N1 outright.

300 and 1200 are the only two speeds the stock module can produce, so they are the only places the
two builds can meet.  Two SwiftLinks can meet anywhere in the table - including on a setting neither
of them chose, if both cartridges are configured the same way, which is what adopt mode is for.

**If the two ends are set to different rates**, there is no rate negotiation anywhere in the
protocol to rescue them.  On real hardware the receiving 6551 samples at the wrong bit period, so the
characters arrive as garbage or raise framing and overrun errors; the NMI pushes every one of those
through `reportLinkError` `$E3BC` -> `countLinkError` `$E73F`, and a non-zero `linkErrorCount`
`$E047` makes `runLinkStateMachine` restart byte sync no matter which phase it was in.  The expected
symptom is a link that sits in phase 2 for ever: hold C= and the border stays red / light red and
never turns green, and the game never gets past "OPPONENT PICKING GAME/MAP."  Fix it by putting both
ends on the same hot key.

That failure has **not** been observed, and it cannot be on this test rig: VICE's ACIA paces whole
bytes at whatever rate the control register asks for but does not shift them bit by bit, so two
emulated ends set to different rates do not garble each other.  A deliberate 38400-against-300
mismatch was run for 246 seconds (`highRateReport.md` section 7) and was the *healthiest* pair in
that whole report - both ends reached phase 3, no interval carried nothing, not one receive-ring
overflow or bad character at either end.  The pair ran at the slower end's rate, because the packet
layer is lock-step and will not send the next thing until the previous exchange completes, so the
slow peer paces the fast one for free.  What that establishes is that neither state machine falls
over on the mismatch itself; what it cannot establish is what the error storm on a real line would
do.

One exception is automatic: if a real Hayes modem answers with a verbose `CONNECT 1200` result code,
the sniffer at `$E7A9` switches that machine to index 3 (1200 baud) on its own, exactly as the stock
driver did.

---

## 5. What was verified, and what was not

Everything below is from `testReport.md` and the eight companion reports it folds in -
`realtimeReport.md` (two machines at true C64 speed, 300 and 2400, and the transmitter fix),
`hotkeyReport.md` (the Commodore-key hot keys), `strapReport.md` (the `$DF00` jumper),
`rate1200Report.md` (1200 baud and the `CONNECT 1200` sniffer), `highRateReport.md` (4800, 9600,
19200, 38400 and a rate mismatch), `hardenReport.md` (the page latch), `wedgeDiagnosis.md` (why 38400
used to wedge) and `portCompatReport.md` (the current build: adopt mode, the eight-bit guard and
every rate including 38400).  All of it is VICE 3.7.1 (`x64sc`), run on 2026-08-21 and 2026-08-22.
Read those for the full detail; this is the honest summary.

**Where the older reports and this file disagree, this file and `portCompatReport.md` are the current
build.**  The rate advice in `highRateReport.md` and the 38400 retirement in `hardenReport.md` were
both written before the NMI re-entrancy guard and have been overturned; both files carry a note at the
top saying so, and neither has had its measurements rewritten.

### Verified

* **The baud doubling.**  The task brief's claim was checked against independent sources before the
  table was written.  From the test report: *"Two independent sources agree with it, so the claim
  holds"* - cc65's SwiftLink driver `libsrc/c64/ser/c64-swlink.s`, whose `BaudTable` maps 300 to
  `$05`, 1200 to `$07`, 2400 to `$08`, 4800 to `$0A`, 9600 to `$0C`, 19200 to `$0E` and 38400 to
  `$0F` - twice the data sheet's nominal rate for each code - and VICE's own `src/aciacore.c`, whose
  `get_acia_bps()` returns `acia_bps_table[ctrl & 0x0F] * 2` in `ACIA_MODE_SWIFTLINK`.  CMD's
  *SwiftLink-232 Application Notes v1.1* say the same thing in prose.  Re-checked against the
  current cc65 source while writing this file: it still reads that way.
* **The patched disk boots like the stock disk.**  Same options menu, no crash, no hang, no missing
  text (`shots/stockMenu.png` vs `shots/swiftlinkMenu.png`).
* **The right module is loaded.**  After picking COMPETE WITH MODEM OPPONENT, `$E000` read
  `4C 11 E1 4C 9A E2 4C A9 E0 4C F6 E0 4C CB E3 60` - *"byte for byte the SwiftLink build, and
  nothing like the trainer that was there a moment earlier"* - and `$E540-$E56F` held the six
  one-instruction ACIA accessors aimed at page `$DE`.
* **No ACIA register is touched until both modem prompts are answered.**  Watchpoints on
  `$DE00-$DE03` stayed silent through the menu, the module load and both prompts.
* **The module programs the 6551 correctly.**  First accesses, in order: probe pattern `$1E` to the
  control register and read back, then - since the probe became a two-pattern one - pattern `$15` and
  its read-back, control `$15` (8N1, internal clock, 300 baud), drain the receiver,
  read the status, command `$09` (DTR on, RTS low, receiver IRQ on, transmit IRQ off).
  Command `$05` appears whenever there is something to send, and reverts to `$09` when the
  transmit ring runs dry.  (That order has one more step in front of it now: the control register is
  *read* before the first probe pattern is written, which is how adopt mode recovers the user's own
  setting.  The rest is unchanged.)
* **It transmits.**  The bytes handed to the data register were
  `$0D $41 $54 $51 $30 $56 $31 $58 $31 $41 $0D` = CR `ATQ0V1X1A` CR, the answer-mode Hayes
  string, followed by the `$00` stream of `beginByteSyncPhase`.
* **Two machines play each other.**  Two emulator instances cross-connected through a TCP relay (a
  null modem made of sockets) both reached **connection phase 3**, the game's own packet protocol,
  and exchanged thousands of bytes in both directions.  Machine A showed "OPPONENT PICKING
  GAME/MAP." and machine B was on the GAME TYPE menu, so the two had already agreed over the link
  which of them plays side 0 and which side 1.
* **Real time, not warp: at 300 baud the link is healthy and saturates the line.**  Two machines at
  true C64 speed (VICE's monitor has a `warp` command even though it has no `WarpMode` resource) ran
  a 75-second measurement window with no monitor input in it: from t=2 s to t=55 s **almost every
  one-second bucket held exactly 30 bytes in each direction** (54.09 s averages 29.14 and 29.43
  bytes/s), and at 8N1's ten bits per byte 30 bytes/s is exactly 300 baud - 100% of what the line
  can carry.  `linkErrorCount` `$E047` was `$00` at both ends and a tracepoint
  on `countLinkError` (then at `$E403`, now `$E73F`) counted zero events in the window, so no framing, parity or overrun
  character and no ring overflow.  The emulators' own cycle counters confirm they ran at 100.5-100.9%
  of real time throughout.
* **The hot keys work, and the Commodore key in VICE is Tab.**  Under the default symbolic keymap
  (`gtk3_sym.vkm`) `Tab` is matrix row 7 column 5; `Control_L` is the C64 CTRL key, which the game's
  `scanKeyboard` reports as "no key at all", which is why an earlier session's `ctrl+c` did nothing.
  Held with Tab: **C= + C** toggles `carrierOverrideFlags` `$E04A` between `$00` and `$40`, and with
  a "no carrier" DCD forced into `aciaStatusSave` `$E5BC` the sampler was single-stepped both ways -
  override on skips the DCD test entirely and publishes `$E03D` = `$40` (carrier), override off runs
  it and publishes `$00` (no carrier).  **C= + 2** put `baudIndex` `$E055` = 6 and the ACIA control
  register `$DE03` = `$18` (2400), **C= + 3** put both back to 0 / `$15` (300), on a live link and at
  the A/O prompt.  **C= held** really does paint the border: a checkpoint on the module's own
  `STA $D020` at `$E30C` fired with `A` = `$02` (red) and `X` = `$80`.
* **`ciaIcrShadow` is corrected at run time.**  Both running machines read `$E033` back as `$7F`,
  which is what stops the main program's film-buffer routines re-enabling CIA2 interrupts behind the
  driver's back.  Its stored byte in the module image is `$90` and must stay that way, since it is
  inside the frozen region.
* **The 2400-baud transmitter deadlock was found, fixed, and the fix re-measured.**  From
  `realtimeReport.md` section 8: nine 75-second windows at 2400 baud over three boots, 675 seconds of
  measured link time, reached both ways (C= + 2 on a live link and at the A/O prompt), and **both
  directions carried traffic in every single second of every window**.  The state that defined the
  fault - `txCharActive` `$E5BB` non-zero while `aciaCommandShadow` `$E5BE` has bit 2 clear - did not
  occur once in 66 state readings, and the rig's deadlock probe never fired, so nothing in those
  numbers is the test repairing the driver.  What it replaced: the same rig, the same rate, the same
  game state, one machine silent for 225 seconds while its peer flooded `$00`.
* **1200 baud carries a game.**  `rate1200Report.md`: six 75-second windows over three boots, 452
  seconds, 13121 bytes one way and 12109 the other, and **not one second in either direction carried
  zero bytes**.  Per-second buckets ran from 6 to 119 bytes/s, and 119 is 99% of the 8N1 ceiling at
  1200 baud, so the line does get saturated in bursts even though the average is a quarter of it.
* **The receive ring really does overflow, and the driver really does recover.**  1200 baud is the
  first rate at which it was seen: 32 overflows across those six windows, every one proved to be a
  ring overflow rather than a bad character - a conditional tracepoint on the `CPX #$14` inside
  `pushUartRxRing` returned counts identical to the unconditional error tracepoint - and every one
  followed by a byte-sync round that completed and a return to the packet phase.
* **The `CONNECT 1200` auto-baud path selects 1200.**  A fake Hayes modem answered the module's AT
  string with `CONNECT 1200`; the driver sniffed it and left ACIA control `$DE03` = `$17` with
  `baudIndex` still 0, which only `switchTo1200Baud` `$E7B2` can produce.  Driving `$E7B2` directly
  with X = 0, 3 and 6 gave `$15`, `$17` and `$18`, so the three-byte stride of `baudEntryTable` is
  right and not just the one index (`rate1200Report.md` section 5).
* **4800 baud is healthy.**  `highRateReport.md` section 2: four windows, 246 seconds, **none of the
  450 sampling intervals carried zero bytes**, and the error tracepoints recorded **zero** hits of
  either kind - no bad character and no ring overflow.  `uartRxCount` sampled 24 times per machine
  peaked at 7 of 20.
* **9600 and 19200 degraded and recovered on the build before the guard.**  `highRateReport.md`
  section 3: no sampling interval at either rate carried nothing (0 of 442 and 0 of 450); every error
  counted was a ring overflow, the conditional and unconditional tracepoints returning identical
  counts at both rates; and byte-sync rounds were entered and completed at roughly 1.3 to 1.8 a
  second.  The cost was real - 2.1% to 2.3% of transmitted characters lost to the driver writing a
  data register that had not emptied, and the extra line time buying no extra ARQ frames - but the
  link stayed up and the game kept moving.  **On the current build neither rate produced a receive
  error or a lost transmit character at all** (`portCompatReport.md` section 3); the degradation
  described here is what the re-entrancy guard removed, and this bullet is kept as the "before".
* **38400 no longer wedges, and its hot key is back.**  Two machines at true C64 speed, 38400 baud,
  200 seconds with `trace exec e685 if sp < $b4` armed from before the link was opened: **not one
  trace line on either machine**, page 1 untouched below `$01C0` at the end, both machines still
  running game code with the interrupt flag clear.  On the old build the same instrumentation
  recorded 34 nested frames and three stack descents of 8 to 13 ms.  **Seven boots at 38400 on this
  build, none of them wedged** (`portCompatReport.md` section 4.4), where the previous build wedged
  four out of four.  See "Fixed: the 38400 wedge" below.
* **Every rate in the table was measured on this build, and all seven are healthy.**
  `portCompatReport.md` section 3: one boot per rate, two machines at true C64 speed, three windows
  and 170 seconds of measured link time each (38400 got a second boot of 245 s), 1194 seconds over the
  sweep.  **No rate had a single one-second sampling interval carrying nothing in either direction**,
  in 334 intervals per rate; the whole sweep produced **two** rejected characters and **two**
  receive-ring overflows, which are the same two events, one on each machine, both at 1200 baud; and
  `uartRxCount` never reached its 20-byte limit at any rate.  Every window ran at 100.5% of real time
  or better against the emulators' own cycle counters.
* **The transmit-side loss is gone.**  VICE logs `data register written although data has not been
  sent yet` each time the driver writes a data register that has not emptied - one transmitted
  character lost, on emulation and on a real 6551 alike.  The previous build produced them at 0.8% of
  offered characters at 4800, 2.1% at 9600 and 2.2-2.3% at 19200; **every emulator log in the current
  sweep contains zero of them, at every rate, including both 38400 boots.**  `wedgeDiagnosis.md`
  section 4 predicted exactly that, having traced the overwrite to a handler pass interrupted between
  `lda txCharActive` and `dec uartPendingCount`.
* **Adopt mode was measured with two machines linked, not just one.**  `testAdoptPair.py`,
  `portCompatReport.md` section 5: both machines booted to the A/O prompt with the chip untouched
  (`aciaPageLatch` and the adopt entry both `$00`), the control register then written from the
  monitor as a configured cartridge would have left it, and the link opened and measured for 75
  seconds.  `$98` (2400 baud, **two stop bits**) came through **whole** on both machines; `$B8`
  (seven data bits) came through as `$98`, **only** the word-length bits corrected; `$00` fell back to
  `$15` and the wire really ran at 300 baud (6.9-30.0 B/s per second, against 5.9-12.0 at every rate
  from 1200 up).  None of the three windows had a second carrying nothing, in 148 per run.
* **The `$DF00` fallback adopts from the real chip.**  `portCompatReport.md` section 9.1 has both
  strap traces on the current build.  On `$DF00` the driver reads `$DE03` (open bus, `$F6`), fails the
  first probe pattern there, then **reads `$DF03` before writing to it** and adopts that value; on
  `$DE00` the very first cartridge access in the whole session is the control-register read, ahead of
  either probe pattern.  Both runs ended with all six accessors and `aciaPageLatch` `$E5BF` on the
  right page, zero accesses to the other page's mirrors, and a link open.
* **A rate mismatch did not wedge either end.**  38400 on one machine against 300 on the other, 246
  seconds: both reached phase 3, none of 474 sampling intervals carried nothing, and there was not a
  single ring overflow or bad character at either end.  The pair ran at the slow end's rate, because
  the lock-step packet layer will not send the next thing until the previous exchange completes.  Read
  section 4 for what this does and does not say about real hardware.
* **Every hot key in the baud table programs the chip, first press.**  `$15`, `$17`, `$18`, `$1A`,
  `$1C`, `$1E` and `$1F` have all been seen in the ACIA control register with the matching
  `baudIndex`.  `$1F` (38400) was one of them; its hot key was taken away for one build after the
  wedges and is back, which `testAdopt.py` re-checked - see the C= + 8 bullet below.
* **The `$DF00` strap fallback is taken and works.**  With VICE started as `-acia1base 0xDF00` the
  probe writes `$1E` to `$DE03`, fails to read it back, re-aims all six accessors at `$DF00`, and
  every one of 796 subsequent register accesses lands on `$DF00-$DF03`; two machines strapped that way
  reach connection phase 3 and exchange ARQ frames at 300 and 2400 baud, and a watchpoint on the
  register mirrors `$DE04-$DEFF` / `$DF04-$DFFF` never fired.  `strapReport.md` has the whole run, and
  the two probe weaknesses it lists at the end have since been closed - see the next bullet.
* **The probe is hardened, and the hardening was re-measured on the `$DF00` strap.**  `testStrap.py`
  was re-run against the current build with watchpoints on both pages: over an 800-stop log covering
  **two** link opens, `$DE03` was written **once** and read **once** - the single probe pattern `$1E`
  and its read-back, which returned `$00` and failed - and every other one of the 800 accesses landed
  on `$DF00-$DF03`.  `aciaPageLatch` `$E5BF` read back `$DF`, all six accessor operands read `$DF`,
  the mirror tripwire on `$DE04-$DEFF` / `$DF04-$DFFF` recorded 0 stops, and the machine sent its
  `ATQ0V1X1A` string and its byte-sync `$00` flood as before.  The old build wrote `$1E` into `$DE03`
  once per link open and once per speed hot key; this one writes it once per cold start.
* **The latch was then re-measured with the hot keys included, on both straps, on a phase-3 link.**
  `hardenReport.md`: two machines per strap, two link opens and six landed speed hot keys per machine
  - eight calls to `loadBaudParameters` - and the probe ran **once**.  During the whole hot-key sweep
  the control register took exactly six writes, `$17 $18 $1A $1C $1E $15`, one per key in the order
  pressed, with no probe pattern in front of any of them.  On the `$DF00` strap each machine touched
  `$DE00-$DE03` exactly twice in the entire session - the one probe write and its one failed
  read-back, which returned `$F6` - and not at all during the sweep or during a 20-second census of
  3281 register accesses.  On the `$DE00` strap `$DF00-$DF03` was never touched at all.
* **Both probe patterns have now been seen to run.**  On the `$DE00` strap the chip answered `$1E`
  and then `$15`, which is the first time the second pattern has executed at all.  On the `$DF00`
  strap the first pattern already fails, so the second is never written.
* **C= + 8 selects 38400 again.**  `testAdopt.py`, one machine on a live link: pressing it put
  `baudIndex` `$E055` = `$12` (18) and the ACIA control register = `$1F`.  (The earlier
  `hardenReport.md` measurement of the same key - `baudIndex` and the control register unchanged, the
  keystroke reaching `$E38A` and being answered with `$FF` - was of the build in which its table byte
  had been blanked, and is the "before" for this one.)
* **Adopt mode uses the port's own settings, and corrects only what cannot work.**  `testAdopt.py`,
  one machine, **46 of 46 checks passed**.  At the A/O prompt, before the module had touched the chip:
  `baudIndex` `$E055` = `$15` (21, adopt - the disk default), the adopt entry's control byte `$00` and
  the page latch `$00`, i.e. nothing decided yet.  With `$18` then written into the ACIA's control
  register from the monitor - which is what a configured cartridge would have left there - the link
  came up with the adopt entry holding `$18`, the control register reading `$18` and `baudIndex` still
  21.  Seven more presets were fed through the same code by clearing the page latch and pressing
  C= + 3 (which re-probes and imposes 300 baud) and then C= + U (which goes back to the port's own
  setting): `$18` -> `$18` and `$9F` (38400 with two stop bits) -> `$9F`, copied through untouched;
  `$5A` (six data bits) -> `$1A` and `$3C` (seven data bits) -> `$1C`, the word length corrected and
  the rate left alone; `$00`, `$0C` (external receive clock) and `$10` (internal clock but no rate)
  -> `$15`, the 300-baud fallback.  Every C= + 3 put `baudIndex` = 0 and `$15` in the register and
  every C= + U put it back to 21 and the adopted byte, so switching between adopt mode and a fixed
  rate works in both directions.
* **1200 baud was re-measured after both changes.**  `hardenReport.md` section 6: two boots, four
  75-second windows, 303 seconds at true C64 speed, both machines in phase 3 on both boots, **none of
  592 one-second sampling intervals carried zero bytes**, `linkErrorCount` `$E047` `$00` at all 24
  window edges, and the transmitter-deadlock shape absent from every one of them.  The 22 receive
  errors counted were all ring overflows, at the same rate per second `rate1200Report.md` measured
  before the changes.

### Not verified

* **No real hardware, and no C64 Ultimate.**  Every result above is VICE 3.7.1 with `-acia1mode 1
  -acia1irq 1` and base `$DE00` or `$DF00`.  Nothing has been run on a physical SwiftLink, on a real
  cable, on a real modem, or on the device this whole exercise is aimed at.  Adopt mode in particular
  is only as good as what the device puts in its control register, and no device other than VICE's
  6551 has been asked.
* **Adopt mode was fed by a monitor, not by a configured device.**  Every preset in the adopt table
  was written into `$DE03` from the VICE monitor.  That is the same byte a configured cartridge would
  leave there, and the driver cannot tell the difference, but "an Ultimate set to 9600 in its own menu
  presents `$1C` in the control register" is an assumption this rig cannot test.
* **The 8-data-bits correction was tested for what it writes, not for what it prevents.**  `$5A` and
  `$3C` came back as `$1A` and `$1C`, and a two-machine run pre-set to `$B8` came up `$98`, so the
  word-length bits really are corrected; no run has been made with a peer actually transmitting
  seven-bit characters, because VICE's ACIA is byte level and cannot produce one.  That eight data
  bits are *required* is a reading of the source - the `$FF` test at `$E852`, the `$55` length code,
  the eight-bit checksum - not an experiment.
* **Parity is not adopted, and nothing measures what that costs.**  Parity lives in the 6551 command
  register, which the driver rewrites from constants at four sites, so a port configured for parity
  runs without it (`portCompatReport.md` section 8).  Both ends of a link run this same driver and
  clear the same bits, which is why the `$69` two-machine run worked; no measurement says what a
  device or a peer that really insists on parity would do.
* **38400 after the fix is seven boots, not a soak.**  Two 200-second stack watches, two
  `testHighRates.py` boots carrying 170 and 245 seconds of measured windows, and three `testGuard.py`
  runs.  That is enough to say the runaway does not happen where it used to happen within a minute,
  four times out of four; it is not a statement about an hour of play.
* **38400 is healthy at this game's traffic, not at any traffic.**  Both directions carry about nine
  bytes a second at the point every measurement was taken, which is 0.2% of a 38400 line.  The
  arithmetic that killed the old build - two interrupts per 266-cycle character time against a
  178-cycle handler - still says a *saturated* 38400 line costs more CPU than a C64 has, and nothing
  in this game saturates one, so that case was never provoked on this build.  "It would then overflow
  the ring and resync, rather than wedge" is an argument from the guard's construction plus the
  overflow-and-recover behaviour measured at 1200 here and at 9600/19200 on the previous build; it is
  not a measurement of a saturated 38400 line.
* **The guard's nested path has only ever been exercised in runs that were not at true C64 speed.**
  The 18.5% / 9.4% counts, and the later 8.5% / 8.6% over 405 nested passes, both come from windows
  the emulators ran faster than real time (one of them about eight times faster, an accident recorded
  as such in `portCompatReport.md` sections 2 and 4.3).  The one window measured at true C64 speed -
  90 seconds at 38400, both machines at 100.7-101.1% - counted **3252 handler entries and not one
  nested pass**.  So the path that absorbs a nested NMI is proven to run, and proven to leave
  `nmiHandlerState` at `$00` afterwards; what has not been observed is it running at this game's
  traffic on a machine held to real time.  Tracing also puts a line on the monitor socket per hit, which is
  emulator work the emulated machine does not pay for but the wall clock does.
* **VICE's ACIA is byte level.**  It paces whole bytes at the rate the control register asks for but
  does not shift them bit by bit, so no framing or parity error can occur on this rig at all.  Every
  "zero bad characters" and "every error was a ring overflow" above is a statement about this
  emulation, not about a noisy real line - and the rate-mismatch failure described in section 4
  cannot be reproduced here at all.
* **SwiftLink-to-stock interop was not tested.**  Every two-machine run in every report had SwiftLink
  builds at both ends.  The wire-compatibility claim in section 4 rests on the fact that the frame and
  packet layers are byte-identical, not on an observed run against the stock user-port driver - and
  1200 baud, the rate that pairing would most likely use, has now been measured only SwiftLink to
  SwiftLink.
* **The 38400 wedge has since been explained, and this is what is still missing from the
  explanation.**  `wedgeDiagnosis.md` established the cause - 34 nested handler frames decoded off a
  live page 1, three timed stack descents, and the cycle budget behind them - so the earlier "which
  bytes are stuck" characterisation is no longer where the answer stands.  What that diagnosis does
  *not* contain: the double decrement of `uartPendingCount` is shown as a stack frame stopped one
  instruction short of the `DEC`, not as a counted `DEC`/`INC` imbalance during a storm, because
  instrumenting it prints thousands of lines a second into a fault that lasts eight milliseconds.
* **The `uartPendingCount` repair probe has never been run against the shape it was written for.**
  It was run on the boot whose CPU had jammed, where no write to any driver byte could have worked
  (`highRateReport.md` section 6.3).
* **The transmitter fix is proven by absence, not by proof.**  675 seconds at the rate that used to
  fail within twenty says the deadlock did not recur; it does not say no path into the state remains.
  `healStuckTransmitter` is what makes any remaining path self-healing within one video frame.
* **Why the driver overwrote its own transmit data register is traced structurally, not counted.**
  VICE counted 0.8% to 2.3% of offered characters lost that way between 4800 and 19200 on the old
  build.  `wedgeDiagnosis.md` section 4 found handler frames interrupted at `$E6C4` and `$E6C7`,
  either side of `dec uartPendingCount` `$E6C9`, which is the mechanism; the confirmation is negative
  rather than positive - with the guard in, the current build's logs contain none of those messages at
  any rate.
* **The 1200-baud overflows are unexplained.**  All 32 were on the same machine, over three boots,
  and none at all on the other.  Not chased, for the same reason as above.
* **One boot per rate, and no long soak anywhere.**  246 seconds per rate on the old build and 170 on
  the current one is enough to say the link keeps moving and recovers; it is not enough to say it
  always will.  38400 is the exception in both directions - four boots on the old build because it
  failed, seven on this one because it had to be shown not to.
* **Timing was measured in one game state.**  Every real-time window in every report sits in the
  side-0/side-1 negotiation - machine A on "OPPONENT PICKING GAME/MAP.", machine B on the GAME TYPE
  menu.  A quieter moment puts fewer bytes on the wire at any rate.  Sections 1-4 of `testReport.md`
  still ran in warp and say nothing about real-time behaviour.
* **The C= + C override was verified against an injected DCD, not an emulated one.**  VICE's 6551
  never sets status bit 6 - the bit a SwiftLink carries DCD on - so "no carrier" had to be written
  into `aciaStatusSave` `$E5BC` at a breakpoint.  That is the exact byte the carrier sampler reads
  and the exact bit the source documents, but a real peer dropping a real DCD has not been observed,
  and the override was never tried with two machines actually playing.
* **Five of the hot keys have never been pressed:** C= + H (DTR), C= + P (hang up), C= + A, C= + O
  and C= + RETURN.  The seven speed keys are the only ones exercised, and on the `$DF00` strap only
  C= + 2 was ever seen to land.
* **The second probe pattern has never been seen to fail.**  On this rig the first pattern decides
  every case: at `$DE00` the chip answers `$1E` and then answers `$15`, and at `$DF00` the open-bus
  read of `$DE03` misses on `$1E` and the second write never happens.  A page that matches one pattern
  and not the other - the case the second pattern exists for - has not been produced, and cannot be
  produced on this rig, because VICE's open bus is not the C64's.
* **No complete game was played**, at any rate or either strap.  The two machines reach the GAME TYPE
  menu and stay there.
* **The disk-load suspend handshake was not instrumented.**  `$E039`/`$E03A` were never watched
  directly, though both read `$00` in the 38400 wedge dumps, which is at least evidence that a stuck
  handshake was not the cause there.  A setup overlay did load over a live link and the link survived
  it, but that is an inference from the screen, not a measurement.
* **W65C51N boards** were not tested and are expected not to work without a timed transmit delay
  (see section 1).

### Fixed: the 2400-baud transmitter deadlock

This is recorded because the earlier documents warned about it and the warning is now wrong.

**The fault.**  At 2400 baud both machines reached connection phase 3 and then, within about twenty
seconds, one machine stopped transmitting for good while the other poured out byte-sync `$00`
characters at one per video frame.  It happened in three consecutive 75-second windows in each of two
runs, whether the rate was chosen on a live link or at the A/O prompt.  No framing, parity or overrun
error was counted at either end while it happened.  The stalled machine was left with `txCharActive`
`$E5BB` = `$01` while `aciaCommandShadow` `$E5BE` = `$09` said the transmit interrupt was off - a
state the driver could not leave, since `startNextTxChar` returns early while the flag is set and the
NMI's transmit half checks the command shadow before it will clear it.  Writing a single `$00` over
`$E5BB` from the monitor brought the machine straight back to life.

**The fix**, three sites and one idea: `txCharActive` means "a byte of ours is sitting in the ACIA's
transmit holding register" and nothing else, so it must never outlive the transmit interrupt that
clears it.

| Site | What it does |
|---|---|
| `aciaSetCommandIdle` (`$E72A`, later `$E408`), called from the NMI's disarm | wrote the command register and cleared `txCharActive` with it - closing the one path that was traced, where an inner NMI pass primes a byte and does `INC txCharActive` while the outer pass finds the ring empty and disarms the interrupt.  **This routine is gone**: the re-entrancy guard closes that path at its source, so the disarm site provably runs with the flag already clear |
| `healStuckTransmitter` `$E057`, now the first thing `startNextTxChar` `$E607` does | once a frame, whatever caused it: if the command shadow says the transmit interrupt is disarmed, clear the flag.  Returns the same `A` the old `LDA txCharActive` did, so the caller is unchanged |
| `aciaSetControlIdle` `$E04C`, called from the `jsr` at `$E35C` inside `loadBaudParameters` `$E353` | a rate change forgets any byte the driver believed was in the chip, so a speed hot key landing on a character in flight cannot strand the flag |

The first closes the traced path, the second makes the whole class of paths self-healing within one
video frame, the third removes the rate change as a way in.  The measurement is in the verified list
above and in `realtimeReport.md` section 8.

### Fixed: the 38400 wedge, and the NMI re-entrancy behind it

**The fault.**  `highRateReport.md` sections 5 and 6: four boots, four wedges, no recoveries, every
one within about a minute of the link coming up.  The four looked different - both machines frozen in
phase 3 with both receive rings full; a machine flooding byte sync for ever at one byte per video
frame; a machine's game back on the plain OPTIONS menu; and `*** Main CPU: JAM at $0007`, the 6502
executing zero page with its bank register overwritten.

**The cause**, established in `wedgeDiagnosis.md` and not inferred: the handler could be re-entered
without bound.  Reading the 6551's status register is the interrupt acknowledge, so from that
instruction on the chip is free to raise a fresh edge while the handler still has 100 to 240 cycles
of work left, and a 6502 does not mask NMI.  At 38400 with traffic both ways there are two events per
character time - one every 133 cycles - against a handler that costs 175 to 266, so service time
exceeded inter-arrival time and the nesting grew without bound.  A machine stopped mid-runaway had
**34 complete nested frames** decoded off page 1, and three descents from a healthy stack to an
exhausted one were timed at 8.4, 11.5 and 12.5 ms.  Once the pointer wrapped, every `RTI` popped a
program counter, a flag byte and a saved `$01` belonging to a different frame; the four different
wedges are that one failure photographed at different moments.

**The fix**, in `commNmiHandler`, two co-operating parts:

* **the guard** `nmiHandlerState` `$E5BD`.  A second NMI that finds a pass already running does not
  run the handler body: it adds one to that byte as a note and returns, which costs six bytes of
  stack and about fifty cycles.  It cannot nest again, because a nested pass never reads the status
  register - `/IRQ` stays asserted, the shared `/NMI` line stays low, and an edge-triggered NMI
  cannot fire again until the running pass acknowledges.  The depth is bounded at one nested frame
  whatever the line rate, with no arithmetic about cycles per character needed to make it true.  The
  one qualification: the guard byte is read and then incremented, two instructions apart, and an NMI
  landing between them would let one extra pass through.  Our own chip cannot do it - it has not been
  acknowledged yet at that point, so there is no edge to take - and only a foreign NMI arriving first
  opens the window at all.  The order is that way round on purpose: increment-then-read would make
  the same window leave the byte stuck at 2 and the driver deaf for good, where this way the worst
  case is the single nesting the guard already tolerates and the byte still ends at 0.
* **the drain loop**.  Refusing a nested pass would otherwise lose the interrupt the old comment
  warned about - a dropped `TDRE` stalls the transmitter for good - so the running pass loops: it
  services whatever the status register shows, and at `nmiIdle` it decrements the guard and goes
  round again whenever the decrement leaves a note behind.  The transmit half loops from the other
  end too, going back for another look after feeding the chip instead of returning.  The decrement
  comes *first* at `nmiIdle`, so from that instruction the guard reads zero and any NMI arriving
  during the exit sequence is a full pass that drains everything; there is no instant at which work
  can be left behind with the interrupt swallowed.

**Measured after the fix** (2026-08-22, VICE 3.7.1, two machines at true C64 speed, 38400 baud):

* `testStack.py`, **two boots, 200 seconds each**.  The tracepoint `trace exec e685 if sp < $b4` -
  one NMI frame below the deepest entry any healthy machine has ever made - printed **nothing at all,
  on either machine, in either boot**, where the same instrumentation on the old build produced the
  staircases above.  In the second boot the stack tripwires were armed as well (`break exec e685 if
  sp < $20` and a watchpoint on any store into `$0100-$0140`) and **neither fired**; both machines
  were in connection phase 3 with `linkErrorCount` = 0 when the window opened, page 1 was untouched
  below `$01C0` at the end, both were still executing game code with the interrupt flag clear, and
  the relay counted 2638/2040 and 2954/2461 bytes crossing in the two directions.
* `testGuard.py`, **the guard's own path, counted**: with both machines in phase 3, tracepoints on
  the handler entry and on `nmiLeaveNote` counted **65 entries of which 12 were nested (18.5%)** on
  one machine and **64 of which 6 (9.4%)** on the other, over five seconds.  So second NMIs really do
  arrive while a pass is running - roughly one entry in five to one in ten, at nothing like a
  saturated line - and every one of them is now six bytes of stack and a note instead of a full
  re-entrant pass.  `nmiHandlerState` read `$00` on both machines afterwards, which is its resting
  value.
* **1200 baud still works after the rewrite** (`testGuard.py` again, two machines, the rate a stock
  user-port peer can also produce): both ends reached connection phase 3, the control register read
  `$17` and `baudIndex` 3 on both, `linkErrorCount` was `$00` at the end, and a ten-second window
  counted 6045 and 4719 handler entries of which **39 (0.6%) and 52 (1.1%) were nested** - two orders
  of magnitude rarer than at 38400, which is what the cost model says should happen.
  `nmiHandlerState` rested at `$00` on both.  No throughput numbers were taken: this is a "does the
  ordinary rate still come up and stay error-free" check, not a repeat of `rate1200Report.md`.
  (Both of those `testGuard.py` windows were later found not to have been held to real time - see the
  qualification in the "Not verified" list.)
* **The whole rate table, re-measured** (`portCompatReport.md` section 3, `testReport.md` section
  11): seven boots, one per rate, two machines at true C64 speed, 170 seconds of measured link time
  each.  No rate had a sampling second carrying nothing in either direction; the sweep produced two
  rejected characters and two ring overflows, both at 1200 baud, both recovered from; and every
  emulator log contains zero "data register written although data has not been sent yet" lines, where
  the old build produced them at 0.8-2.3% of transmitted characters from 4800 up.
* **The margin at 38400, measured rather than argued** (`portCompatReport.md` section 4.2): in a
  90-second true-speed window the handler was entered 1632 and 1620 times with **zero** nested passes,
  but 817 and 1027 of the gaps between consecutive entries were under 300 cycles with a **median of
  266 - exactly one character time at 38400** - against a handler that costs 175 cycles for one half
  and 256-266 for both.  The margin is between zero and ninety cycles, and it was never lost here.

**What this does not claim.**  A *saturated* 38400 line still costs more CPU than a C64 has: two
interrupts per 266 cycles cannot be serviced in 266 cycles, so a burst in both directions at once
would still overrun the receive ring and make the frame layer resynchronise, the way 9600 and 19200
did before the guard.  Nothing in this game saturates a 38400 line - it produces about nine bytes a
second each way - so that case has not been provoked on this build, and what would happen then is an
argument from the guard's construction, not a measurement.  What the guard changes is the failure
mode: degradation instead of a dead machine.  It does not change throughput, and throughput was never
the problem.

### Known race, by design

Reading the 6551 status register releases `/IRQ`, so a transmit or DCD event arising between that
read and the rest of the pass raises a fresh NMI.  Since the guard went in, that NMI does not
re-enter the handler body: it leaves a note and returns, and the running pass picks the work up on
its next time round the drain loop.  What it costs now is six bytes of stack and about fifty cycles
per occurrence, rather than a duplicated byte, a double-decremented `uartPendingCount` and a
possibility of the stack running away.  A foreign NMI - RESTORE - that lands inside a pass is
swallowed rather than chained, which is the one thing given up; both chain targets in this game are a
bare `RTI` (`$E010` on disk, `$1298` once the game has patched the vector), so nothing observable
goes with it.

The transmitted characters VICE reported the driver overwriting (0.8% at 4800 rising to 2.3% at
19200) were traced in `wedgeDiagnosis.md` section 4 to exactly this re-entrancy - an outer pass
interrupted between `lda txCharActive` and `dec uartPendingCount`, which then counted the same byte
off twice and wrote the data register twice.  That path is closed by construction now.  It has not
been re-measured at those rates.

---

## 6. What changed inside the module, address by address

`checkAbi.py` prints the raw ranges; some are split by bytes that happen to be identical either
side.  Grouped by routine:

| Range | Size | Was | Is now |
|---|---:|---|---|
| `$E04C-$E055` | 10 | `dtrToggleMask`, `userPortDdrbValue`, `userPortDtrDropped`, `userPortIdleTable`, `serialShiftPatternTable` and its 1200-baud answer entry - CIA2 bit-banger constants | `aciaSetControlIdle` at `$E04C` and `clearTxCharActive` at `$E04F`: program the control register, then forget any byte the driver believed was in the transmitter |
| `$E057-$E05E` | 8 | `bitPeriodHi`, `txPaceReload` and the stock six-byte `baudParameterTable` | `healStuckTransmitter`: `txCharActive` set with the transmit interrupt disarmed cannot be true for any healthy reason, so clear it on sight.  Returns the same `A` the load it replaced did |
| `$E327-$E331` | 11 | C= + H: `EOR dtrToggleMask` / `STA CIA2_PRB`, toggling PB2 and PB5 | `EOR #$01` into `aciaCommandShadow` -> `aciaSetCommand`; tail padded with `NOP` |
| `$E342-$E34F` | 14 | two hard-coded tests, `CMP #$B3` (300) and `CMP #$B1` (1200) | a backwards walk of the eight-entry `baudEntryTable`, same three-byte stride, starting at index 21 - the adopt entry, C= + U - and running down through 38400 to 300 |
| `$E38E-$E38F` | 2 | `hangUpModem`'s `JSR dropDtrLine` operand | the same call, following `dropDtrLine` to `$E408`.  This is the only byte pair outside the driver's own block that had to move at all |
| `$E3C7-$E3CA` | 4 | `linkIoVectorTable`'s error pair: `$E408` (the never-selected no-op stub) and `$E403` (`countLinkError`) | both halves now point at `$E73F`, `countLinkError`'s new home |
| `$E402-$E406` | 5 | the `RTS` of `selectFramedLinkVectors` and the first four bytes of `countLinkError` | `LDA #$00` / `STA aciaPageLatch` and then the `RTS`: the cold-start clear of the ACIA page latch, in the one routine `initCommModule` calls exactly once |
| `$E408-$E40E` | 7 | `ignoreLinkError`, the raw-mode error hook - dead, because `initCommModule` always selects `countLinkError` | `dropDtrLine`, moved out of `$E574` so that `silenceCia2` could leave the NMI block.  It replaced `aciaSetCommandIdle`, which the re-entrancy guard made unnecessary |
| `$E353-$E35E` | 12 | `loadBaudParameters`: three bytes into `$E056-$E058` via `sta $DF59,y` | `aciaDetect`, then the entry's control byte into `bitPeriodLo` and through `aciaSetControlIdle` into the ACIA control register.  Still returns `A = $FF` |
| `$E540-$E578` | 57 | `configureUserPortLines` (CIA2 DDRB, PRB, PRA, both timer latches, the SDR write) and `dropDtrLine` | `configureUserPortLines` at `$E540` (control, command `$09`, drain, status); the six one-instruction ACIA accessors at `$E551-$E56E`, with `aciaSetControl` now falling through a `NOP` into `aciaGetControl` so that a write and its read-back are one call; `silenceCia2` at `$E56F`, moved down out of the NMI block |
| `$E587-$E588` | 2 | `restartUart`'s first call, `JSR clearUartState` | `JSR aciaResumeReceiver` |
| `$E593-$E5BA` | 40 | `setCiaNmiMask` (the mask into `CIA2_ICR`, `CIA2_CRA`, `CIA2_CRB`) and `clearUartState` | `JSR silenceCia2`, then arm or disable the ACIA receiver IRQ with command `$09` / `$0B` and read the status so no latched IRQ flag holds `/NMI` down - packed tight, with the four `NOP`s gone - and then `aciaResumeReceiver` at `$E5B1`, moved down out of the NMI block into the bytes `clearUartState` no longer needs |
| `$E5E8` | 1 | the carrier sampler's `BEQ` displacement | the same branch, retargeted to `aciaDetectDone` `$E654`, which is the bare `RTS` the transmit path's shared exit `L_E662` used to be |
| `$E5EC-$E5F7` | 12 | the carrier sampler reading `CIA2_PRB` bit 4 | `BIT aciaStatusSave`: V = status bit 6 = DCD on a SwiftLink, active low |
| `$E607-$E642` | 53 | `startNextTxChar`: the pacing counter and the CIA2 timer B start of a bit-banged character | call `healStuckTransmitter` in place of the plain `LDA txCharActive`; check the command shadow (DTR up and transmitter idle), the suspend flag and carrier; pop the ring; prime the data register by hand; arm the transmit interrupt with command `$05` |
| `$E645-$E684` | 63 | the rest of the bit-bang transmit start, `setBitPeriodFull`, `setBitPeriod`, `nmiStartNextChar` and the old "not mine" NMI exit | `aciaDetect` at `$E644` (return at once if `aciaPageLatch` is set, otherwise try `$DE00` and fall back to `$DF00`) with its shared `RTS` still at `$E654`; `aciaProbeAdopted` at `$E655`, which reads the page's control register before it writes either probe pattern - the whole of adopt mode's input; and the not-mine NMI exit at `$E67B`, which restores `$01` before chaining through `nmiChainVector` |

| `$E688-$E732` | 169 | `commNmiHandler`'s bit-level receive and transmit halves and the CIA2 ICR juggling | `commNmiHandler` with the re-entrancy guard and the drain loop, `aciaSetPage` `$E6FA`, the eight-entry `baudEntryTable` `$E710` and `queueAndKickTx` `$E727`.  The block is exactly full: not one spare byte in it |
| `$E73C-$E742` | 7 | `queueByteForTransmit`'s `INC uartPendingCount` and `RTS` | `JMP queueAndKickTx` - the byte must also start the transmitter now, and a tail jump rather than a `JSR`/`RTS` pair leaves four bytes at `$E73F` for `countLinkError` |

Some structural moves are worth calling out because they are what made the whole thing fit:

* `baudEntryTable` could not stay at `$E059`.  Seven entries need 21 bytes and `$E05F` is
  `linkStatusBorderTable`, so the table moved into the space freed by the bit-banged receiver, first
  to `$E6FD`, then `$E706`, and now `$E710` with an eighth entry on the end.  The *index* semantics
  did not move: 0 is still 300 baud and 3 is still 1200 baud, the stride is still three, and 21 - the
  new one - is adopt.
* `setCiaNmiMask` had to grow, and nothing below it may move, so its CIA2 half was moved out to
  `silenceCia2` - into the freed receiver space at `$E71E`, then `$E727`, and now back down to `$E56F`
  where the accessors and `dropDtrLine` left room, because the NMI block needed those twelve bytes.
  The four `NOP`s it once left at `$E597` are gone with the same shuffle.
* The re-entrancy guard, the drain loop and the read-before-you-write probe together needed about
  fifty more bytes than the block round `commNmiHandler` had, which was six.  Three routines left the
  block to pay for them - `aciaResumeReceiver` to `$E5B1`, `silenceCia2` to `$E56F` and `dropDtrLine`
  to `$E408` - two were deleted (`aciaSetCommandIdle`, whose second half the guard makes provably
  unnecessary, and `clearUartState`, whose whole inline block was one byte `clearTxCharActive`
  already clears), one dead stub and one duplicate `RTS` went with them, and the handler itself gave
  back eight bytes to some tighter tests.  `ADDRESS_CHANGES.md` has the old-to-new map.
* The two-pattern probe needed 15 more bytes than the one-pattern probe it replaced, and the freed
  receiver space had exactly three bytes of slack left in it.  Six of the fifteen came from making
  `aciaSetControl` fall through into `aciaGetControl`, so that a write and its read-back are one call
  instead of two; the rest came from shifting `baudEntryTable`, `queueAndKickTx` and `silenceCia2`
  nine bytes down the block and moving `aciaSetCommandIdle` out of it entirely, into the seven bytes
  `ignoreLinkError` had been occupying since 1988 without ever being called.  `countLinkError` moved
  in turn, from `$E403` to `$E73F`, to free the five bytes `selectFramedLinkVectors` needed in order
  to clear the ACIA page latch on a cold start; the four bytes it landed in came from turning
  `queueByteForTransmit` into a tail `JMP`.  Nothing that the game or the reports name by address
  moved, and `scanIoAccess.py` still finds the six accessors and nothing else naming the cartridge
  page - in either of its passes.

The seventeen dead stock-UART constants that used to sit at `$E04C-$E05E` are gone, and the
transmitter-deadlock fix lives in the bytes they vacated.  They were `dtrToggleMask` `$E04C`,
`userPortDdrbValue` `$E04D`, `userPortDtrDropped` `$E04E`, `userPortIdleTable` `$E04F`,
`serialShiftPatternTable` `$E051` and its 1200-baud answer entry `$E054`, `bitPeriodHi` `$E057`,
`txPaceReload` `$E058` and the old `baudParameterTable` `$E059` - all of them CIA2 timing values that
nothing had read since the software UART was replaced.  The fix needs three separate places to be
able to clear one flag and the `.res` gaps inside the UART layer are all five bytes or fewer, which
is why the shared parts landed here.  The two live bytes in the middle are why `checkAbi.py` reports
two ranges and not one: `baudIndex` `$E055` and `bitPeriodLo` `$E056`, the latter reused to hold the
live ACIA control byte, which is a convenient thing to peek at from a monitor.

### Guaranteed unchanged

`checkAbi.py` enforces the first three; the rest are unchanged in fact, and the changed-range list
above is exhaustive.

| Region | Why it must not move |
|---|---|
| `$E000-$E017` | the seven jump-table entries - `$E000` per-frame request, `$E003` link control, `$E006` get byte, `$E009` send byte, `$E00C` per-frame poll, `$E012` keyboard hook (whose operand the game patches to its own `scanKeyboard`), `$E015` key entry - plus the constants wedged among them: the spare `RTS` at `$E00F`, the `RTI` byte at `$E010` that `nmiChainVector` points at until the game patches it, and `$E011` `isOriginateMode`, which the game writes from the A/O prompt |
| `$E01D-$E047` | shared variables the game reads and writes: packet buffers, host counts, `$E030` build id, the `$E031/$E032` NMI chain vector, `$E033` `ciaIcrShadow`, the `$E039/$E03A` suspend handshake, `$E03B` link active, `$E03C/$E03D` link status - including the leftover junk bytes `$44 $54 $48` ("DTH") at `$E045-$E047` |
| `$EC00-$EFFF` | the thirteen utilities the map generator overlay calls: `$EC00`, `$EC0F`, `$EC1C`, `$EC2B`, `$EC54`, `$EC58`, `$EC91`, `$ECF3`, `$ED43`, `$EE26`, `$EE4A`, `$EEB9`, `$EEDD` |
| `$E111-$E2FF` | the packet and chat layer |
| `$E40F-$E533` | the four ring buffers |
| `$E756-$E908` | the modem state machine, the connection phases and the byte-sync handshake |
| `$E909-$EAB2` | the frame / ARQ layer: length, control, sequence, checksum, retransmit |
| `$EAB7-$EBFF` | packet state, `remoteCodeBuffer` `$EB03`, and the Hayes strings, stored back to front at `$EBD6-$EBFC` (`+++` / `ATH0` at `$EBD6`, `ATQ0V1X1A` at `$EBE3`, `ATQ0V1X1D` at `$EBF0`) |

`$E030` deserves its own line: it **must** stay `$00`.  It is the "which module am I" byte, and the
game reads it in five places.  A `$FF` there would make the game believe the solo trainer is
resident, reload track 34 over the top of this module every time a modem game starts, suppress the
hang-up handshake, and rewrite `$0BA5` so the game becomes a solo game.

---

## 7. If you are going to modify this

Three things found by inspection that the driver must keep doing.  They are recorded in full in
`DESIGN_NOTES.md`.  Three more were found by measurement and are just as load-bearing:

* **The NMI handler must not be re-enterable.**  Reading the status register is the acknowledge, so
  the chip is free to raise a fresh edge for the rest of the pass; at any rate where the events
  arrive faster than a pass completes, unbounded nesting eats page 1 in milliseconds.  The guard is
  `nmiHandlerState` `$E5BD`, and the property that makes it work is that a nested pass **never reads
  the status register** - `/IRQ` stays asserted, so no further edge can be raised until the running
  pass acknowledges.  Anything added to the nested path that touches the status register throws that
  away.
* **A refused interrupt must be handed on, not dropped.**  The nested pass leaves a note and the
  running pass loops on it, and the decrement at `nmiIdle` comes *before* the test, so a note left
  during the exit sequence turns the next NMI into a full pass instead of being lost.  A dropped
  `TDRE` stalls the transmitter for ever, which is the failure the source used to warn about.
* **Eight data bits are not optional.**  The frame layer sends `$55` length codes, raw sequence bytes
  and rotate-and-add checksums; it is not 7-bit clean.  Adopt mode may take anything else from the
  port's own configuration, but the word length is corrected at `$E665` and must stay corrected.

1. **The NMI handler has to be bank-safe.**  `readFilmByte` `$5874`, `writeFilmByte` `$5893` and
   `copyPageUnderIo` `$58B2` set `$01 = $34` to reach the game-film buffer in the RAM under
   `$D000-$DFFF`, and they mask CIA2 while they do it.  That was airtight for the stock driver,
   whose interrupt *was* a CIA2 interrupt.  A SwiftLink interrupt is not, and `SEI` does not mask
   NMI, so the handler can and will fire with the I/O area banked out - for about 2600 cycles at a
   time in `copyPageUnderIo`, roughly ten character times at 38400 baud.  The handler therefore
   saves `$01`, forces `$35`, and restores the whole saved byte on both exits.
2. **`ciaIcrShadow` `$E033` must be pinned at `$7F` at run time.**  Those same routines write that
   byte straight back into `CIA2_ICR` after every access.  Its stored value in the module image is
   `$90` and cannot change (frozen region), so `setCiaNmiMask` corrects it through `silenceCia2` on
   every state change.  Clearing the mask is not enough on its own: a source that latched before the
   mask was cleared keeps `/NMI` low, so `silenceCia2` also *reads* `CIA2_ICR` to release it.
3. **Never index into the ACIA page.**  The stock `loadBaudParameters` wrote its three bytes with
   `sta $DF59,y`, and an indexed store on a 6502 performs a dummy read from the un-carried address
   first.  With a SwiftLink strapped to `$DF00` those dummy reads land on the mirrored registers,
   and one of them mirrors the **data register**, whose read consumes a received byte.  The
   replacement addresses `$E056` directly, and the six accessors are the only code in the module
   that names an address in `$DE00`/`$DF00` - they are self-modified by `aciaSetPage`.

Also useful: the spec files were written before the driver and are the contract it was built
against.  `spec_uart.md` gives the exact behaviour, range and callers of every hardware-touching
routine in the stock module, plus a hazards checklist.  `spec_link.md` covers everything that
surrounds the UART - the suspend handshake, carrier detection, the connection phases, the hot keys,
the baud table and the build id - and ends with a checklist and the open questions (which clones
swap DCD and DSR, whether to use CTS for flow control during disk loads).

---

## 8. Files

| File | What it is |
|---|---|
| `swiftlinkDriverE000.s` | the module, fully commented; `ca65` source for all 4096 bytes |
| `build.sh` | assemble, size-check, and install onto a copy of the game disk |
| `link.cfg` | places the module as a flat 4096-byte image at `$E000` |
| `injectDriver.py` | writes the image into track 18 sector 7 and track 34 sectors 1-15, re-encrypting |
| `checkAbi.py` | proves the frozen regions and entry points are intact |
| `spec_uart.md` | the hardware contract of the stock UART layer, routine by routine |
| `spec_link.md` | everything above the UART that the replacement has to satisfy |
| `DESIGN_NOTES.md` | the three inspection findings above, in full |
| `ADDRESS_CHANGES.md` | the two old-to-new address maps, and the table of report conclusions later work overturned |
| `testReport.md` | what was run in VICE, what was observed, and the loose ends |
| `realtimeReport.md` | the two-machine runs at true C64 speed: throughput at 300 and 2400, errors, the 2400 transmitter deadlock, and the three-site fix re-measured over 675 seconds |
| `hotkeyReport.md` | the Commodore-key hot keys: which host key C= is, C= + C, the border paint, the baud keys |
| `strapReport.md` | the `$DF00` strap option: the probe taking the fallback, two machines at `$DF00`, and whether anything else in the game can reach the cartridge page |
| `rate1200Report.md` | 1200 baud with two machines in real time, the first receive-ring overflows seen anywhere, and the `CONNECT 1200` auto-baud path |
| `highRateReport.md` | 4800, 9600, 19200 and 38400, plus a deliberate 38400-against-300 rate mismatch: which rates survive, which degrade, and the four 38400 wedges.  **Its "must not be selected: 38400" recommendation is overturned**; the note at the top of the file points at the newer result |
| `hardenReport.md` | the page latch and the 38400 retirement: cold detect on both straps, the probe not re-running on a live link, nothing written to the other page, C= + 8 doing nothing (in that build), and a 1200-baud regression check.  **The retirement was reversed**; same kind of note at the top |
| `wedgeDiagnosis.md` | why 38400 used to wedge: 34 nested NMI frames decoded off a live page 1, three timed stack descents, and the cycle budget behind them |
| `portCompatReport.md` | the current build: every rate including 38400, adopt mode on two machines, the eight-data-bit guard, the unusable-value fallback, what adopt does not cover, and both straps re-probed |
| `testAdopt.py` | adopt mode and the 38400 hot key: presets written into the ACIA's control register and read back out of the driver's choices |
| `testAdoptPair.py` | the same, with two machines: the control (and command) register pre-set on both, then the link opened and measured |
| `testGuard.py` | the re-entrancy guard's own path, counted: handler entries against nested entries on a live link |
| `testStack.py`, `testWedge.py`, `testCost.py` | the wedge instrumentation: the deep-nesting tracepoint and stack-page tripwires, the pass/exit pairing, and the per-pass cost measurement |
| `viceHarness.py`, `testBoot.py`, `testModemSelect.py`, `testTwoMachines.py`, `testRealtime.py`, `testHotkeys.py`, `testStrap.py`, `testStrapPair.py`, `test1200AutoBaud.py`, `testHighRates.py`, `testHarden.py` | the test rig; `SWIFTLINK_ACIA_BASE=0xDF00` puts the whole rig on the other strap |
| `parseTrace.py` | re-parses a saved VICE tracepoint log into an ordered access list and a per-page census |
| `scanIoAccess.py` | static scan: every instruction in the whole game that can reach `$DE00-$DFFF` |
| `probeInput.py`, `probeFire.py`, `probeModemPrompt.py`, `probeBankOut.py`, `probeOpenBus.py` | small experiments used to work out how to drive the game headlessly, and two about the cartridge page |
| `testLogs/stack.gd384a.txt`, `stack.gd384b.txt` | the two 38400-baud runs after the fix: the deep-nesting tracepoint that printed nothing, and the run with the stack tripwires armed |
| `testLogs/guard.gu384.txt`, `guard.gu12.txt` | the runs that counted handler entries against nested entries, at 38400 and at 1200 |
| `testLogs/adopt.adoptG.txt` | the adopt-mode session: every preset, every hot key, 46 of 46 checks |
| `testLogs/portCompat300.txt` ... `portCompat38400.txt`, `portCompat38400run2.txt` | the seven-rate sweep of `portCompatReport.md` section 3, one boot per rate |
| `testLogs/portCompatAdopt98.txt`, `...AdoptB8.txt`, `...Adopt00.txt` | the three two-machine adopt runs |
| `testLogs/portCompatStack38400b.txt`, `portCompatGuard38400c.txt` | the 200-second stack watch and the 90-second guard count, both with their own speed readings |
| `testLogs/portCompatStrapDe00.txt`, `...StrapDf00.txt` | both straps re-probed with the adopt read in front |
| `shots/`, `testLogs/` | the screenshots and logs the test report cites |
| `build/` | build output (git-ignored) |

## Addresses that moved, and conclusions that were overturned

Two passes have now relocated internal helpers to make room in a block that never had more than a
handful of spare bytes: the probe-hardening pass moved six, and the adopt-mode and re-entrancy-guard
pass moved five more and deleted four routines outright.  Nothing the game, the ABI or the frozen
regions name by address moved, and `checkAbi.py` still passes.  `ADDRESS_CHANGES.md` has both
old-to-new mappings, and explains why the older test reports still quote the previous addresses: they
are records of runs made against the build of the day, so rewriting them would describe a run that
never happened.

The same rule now covers their *conclusions*.  `highRateReport.md`'s "must not be selected: 38400",
`hardenReport.md`'s 38400 retirement and `strapReport.md`'s two open probe weaknesses have all been
overturned by later work; each file keeps its measurements and carries a note at the top pointing at
the newer result, and `ADDRESS_CHANGES.md` lists the three in one table.  `spec_uart.md` has a note of
the same kind on its hazards checklist.

`checkAbi.py` currently reports **474 of 4096 bytes changed in 27 regions**, all of them inside the
driver's own UART, hot-key and NMI code.
