1098 lines
89 KiB
Markdown
1098 lines
89 KiB
Markdown
# 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.
|