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

639 lines
40 KiB
Markdown

# Whatever the port is set to: adopt mode, the eight-bit guard, and every rate including 38400
> Folded into `testReport.md` as **section 11**, which carries the headline numbers; this file is the
> full record. It supersedes `highRateReport.md`'s rate recommendation and `hardenReport.md`'s 38400
> retirement, both of which now carry pointer notes.
Two earlier decisions were wrong, and this report is the measurement of their replacements.
1. **38400 was made unselectable because it wedged.** That hid a defect instead of fixing it.
`wedgeDiagnosis.md` then established the defect - unbounded NMI re-entrancy in `commNmiHandler` -
and the driver now carries a re-entrancy guard. **38400 is selectable again, and in seven boots
on this build it did not wedge once.**
2. **The driver imposed its own line settings.** `loadBaudParameters` `$E353` wrote `$10 OR rateBits`
at every link open: 8 data bits, one stop bit, internal baud generator, whatever the user's
hardware had been configured for. It now comes up in **adopt mode** and uses the control register
the hardware already holds.
**The one thing that is not negotiable is EIGHT DATA BITS**, and section 6 sets out why with the
instruction that decides it. Parity and stop bits are free; word length is not.
Everything below is VICE 3.7.1 (`x64sc`), NTSC, `-acia1 -acia1mode 1 -acia1base 0xDE00 -acia1irq 1`,
on **2026-08-22**, against a disk built from the current source with `./build.sh` (`checkAbi.py`
prints `ABI CHECK PASSED`). Every rate window and every adopt window ran with **two machines at true
C64 speed**: warp was read back as off through the monitor first, and each window is bracketed by both
emulators' own free-running cycle counters, the slowest of which reads **100.5% of real time**. The
guard and stack runs carry their own speed readings in section 4, and one run that was not
speed-limited at all is reported as exactly that, in section 4.3.
---
## The answers, in two tables
### 1. Every rate, both machines on the rate, 170 seconds of measured link time each
| Rate | Control | Both reached phase 3 | Sampling intervals carrying nothing | Receive-ring overflows `$E505` | Characters rejected `$E73F` | Byte-sync rounds entered / completed | Max `uartRxCount` A/B | Transmit characters lost | **Outcome** |
|---|---|---|---|---|---|---|---|---|---|
| 300 | `$15` | yes | **0 of 334** | 0 | 0 | 6 / 6 | 1 / 1 | 0 | **healthy** |
| 1200 | `$17` | yes | **0 of 334** | 2 | 2 | 8 / 6 | 1 / 14 | 0 | **healthy** (one overflow each, recovered) |
| 2400 | `$18` | yes | **0 of 334** | 0 | 0 | 0 / 1 | 6 / 0 | 0 | **healthy** |
| 4800 | `$1A` | yes | **0 of 334** | 0 | 0 | 0 / 0 | 0 / 6 | 0 | **healthy** |
| 9600 | `$1C` | yes | **0 of 334** | 0 | 0 | 0 / 0 | 0 / 3 | 0 | **healthy** |
| 19200 | `$1E` | yes | **0 of 334** | 0 | 0 | 0 / 0 | 4 / 6 | 0 | **healthy** |
| 38400 | `$1F` | yes | **0 of 334** | 0 | 0 | 0 / 0 | 1 / 3 | 0 | **healthy** |
| 38400, second boot | `$1F` | yes | **0 of 452** | 0 | 0 | 0 / 0 | 0 / 1 | 0 | **healthy**, 245.5 s |
Where the columns come from: the sampling intervals are the one-second buckets of the null modem's
byte counts, both directions, across all three windows of the boot (74 + 74 + 19 per direction). The
two error columns are tracepoint hits summed over both machines, and they only exist in the windows
that carried those tracepoints - `$E73F`/`$E505` in the 20-second window, `$E805`/`$E855` in the
75-second sync window. `uartRxCount` is the maximum of 24 samples taken per machine with the emulator
stopped. The transmit-loss column is VICE's own `data register written although data has not been
sent yet` count over the whole boot, from each emulator's log.
**38400 no longer wedges.** Nothing in this report needs the word "degrades" either: at every one of
the seven rates the link kept both directions moving in every sampling interval, the 20-byte receive
ring never reached its limit, and no machine's CPU jammed.
### 2. The port's own settings, two machines, before and after the link opens
| What the port was set to before the game touched it | What `$DE03` held after the link opened | Link |
|---|---|---|
| `$98` - 2400 baud, 8 data bits, **2 stop bits** (plus command `$69`, even parity) | **`$98`** on both machines - adopted whole | healthy, 75.2 s, 9.00 / 8.98 B/s, 0 dead seconds |
| `$B8` - 2400 baud, **7 data bits**, 2 stop bits | **`$98`** on both machines - only the word length was corrected | healthy, 75.2 s, 8.98 / 8.98 B/s, 0 dead seconds |
| `$00` - a chip nobody has configured (external clocks) | **`$15`** on both machines - the 300-baud fallback | works, 75.2 s, 10.23 / 10.22 B/s, 0 dead seconds |
The old driver would have written **`$15` into all three** of those cases and not looked at what was
there: `baudIndex` `$E055` came off the disk as 0 = 300 baud, and `loadBaudParameters` wrote
`$10 OR rateBits` at every link open. A port configured for 2400 8N2 got 300 8N1; a port configured
for anything at all got 300 8N1.
**One thing adopt mode does not cover, and it is not a defect but it is a limit: parity.** Parity
lives in the 6551's *command* register, not its control register, and the driver rewrites that
register from constants every time it arms or disarms the transmitter. Section 8 has the
measurement.
---
## 1. The build under test
```
$ ./build.sh
wrote ../Modem_Wars_SwiftLink.d64: module installed in track 18 sector 7 and track 34 sectors 1-15
$ python3 checkAbi.py build/swiftlinkDriverE000.bin ../disassembly/build/game_modemDriverE000.orig.bin
bytes changed: 474 of 4096, in 27 region(s)
$E04C-$E055 (10 bytes)
$E057-$E05E (8 bytes)
$E327-$E331 (11 bytes)
$E342-$E34F (14 bytes)
$E353-$E35E (12 bytes)
$E38E-$E38F (2 bytes)
$E3C7-$E3CA (4 bytes)
$E402-$E406 (5 bytes)
$E408-$E40E (7 bytes)
$E540-$E578 (57 bytes)
$E587-$E587 (1 bytes)
$E593-$E5BA (40 bytes)
$E5E8-$E5E8 (1 bytes)
$E5EC-$E5F7 (12 bytes)
$E607-$E61E (24 bytes)
$E620-$E620 (1 bytes)
$E622-$E636 (21 bytes)
$E638-$E63A (3 bytes)
$E63C-$E63D (2 bytes)
$E640-$E642 (3 bytes)
$E645-$E670 (44 bytes)
$E672-$E684 (19 bytes)
$E688-$E6CF (72 bytes)
$E6D1-$E714 (68 bytes)
$E716-$E732 (29 bytes)
$E73C-$E73E (3 bytes)
$E740-$E740 (1 bytes)
ABI CHECK PASSED: every address the rest of the game depends on is unchanged
```
Every changed byte is inside the UART layer or the link-control code that owns it: the ACIA
accessors and their page latch, `loadBaudParameters`, the baud hot keys, the DTR toggle that used to
poke CIA2, the NMI handler, `baudEntryTable`, and the two operands `ADDRESS_CHANGES.md` names -
`$E38E-$E38F`, the `JSR dropDtrLine` inside `hangUpModem`, and `$E3C7-$E3CA`, the error-vector pair.
The three frozen regions - the `$E000` jump table, the shared variables at `$E01D-$E047` and the
whole of `$EC00-$EFFF` that the map-generator overlay calls - are byte-identical to the stock module.
## 2. The rig, and how "true C64 speed" is established
Two `x64sc` processes, each on its own private Xvfb display with its own private `vicerc`, their
serial lines joined by a TCP null modem that counts bytes per direction (`testTwoMachines.NullModemRelay`).
The host serial rate is set to 38400 on both ends so that it can never be the thing limiting the
line; the ACIA control register is what these tests are about.
* Warp is used **only** for the loader. After `bootPastLoader` both emulators are stopped, `warp off`
is issued, and `warp` is read back - every run in this report printed `Warp mode is off.` on both
machines before anything was measured.
* Every measured window is bracketed by `stopwatch` readings taken with **both** emulators stopped, so
the two counts cover the same interval. Emulated cycles divided by 1,022,727 Hz divided by wall
seconds is the speed figure quoted throughout. 75-second windows read **100.5% to 101.6%**;
20-second windows read **102.0% to 103.6%**, which is pause-and-resume overhead landing inside a
shorter window rather than the emulator running fast.
* Nothing writes a driver byte. The only monitor writes anywhere in this report are to the ACIA's own
registers (section 5), which is what a user's hardware configuration would have left there, and the
`baudIndex` writes `testStack.py` uses to pick a rate without a keystroke.
A caution that cost a measurement. The first `testGuard.py` run in this session issued its
`warp off` to a **running** emulator rather than a stopped one, and did not read the setting back;
what came out of it was a trace whose cycle stamps span **62.3 emulated seconds inside an
eight-second read window**, i.e. the machine was running about eight times real time. The likely
mechanism is that a line sent to a running emulator is consumed by the monitor's break-in, so the
command never executed - that was not chased down, because the fix is the same either way: the script
now does what `testHighRates.py` always did (stop both machines, set it, read it back) and brackets
its window with the cycle counters, so the run says for itself. Both were verified on the re-runs.
The lost run is reported anyway, in section 4.3, because of what it accidentally measured.
## 3. Every rate, including 38400
`testHighRates.py`, one boot per rate, both machines given the same rate with the driver's own hot key
at the "PRESS A OR O" prompt, three windows per boot:
* **clean**, 75 s, no tracepoints at all, so the byte counts are honest throughput;
* **sync**, 75 s, with tracepoints on `beginByteSyncPhase` `$E805` and on `sta ackPending` `$E855`,
which count degradations and recoveries directly;
* **errors**, 20 s, with `countLinkError` `$E73F` and the conditional `$E505 if x > $13`, which
separate "a character was rejected" from "the 20-byte ring was full".
Then `uartRxCount` `$E42D`, `connectionPhase` `$E040`, `linkErrorCount` `$E047`, `txCharActive`
`$E5BB` and `aciaCommandShadow` `$E5BE` are sampled 24 times per machine with the emulator stopped.
### 3.1 What the numbers actually say
Every rate carried the same traffic: **about 9 bytes a second in each direction**, 250-odd `$55`
frame-lead bytes per 75-second window, at 1200 baud and above. That is the game, not the line - the
two machines sit at the GAME TYPE menu exchanging the lock-step ARQ frames the protocol keeps alive,
and the rate the wire runs at makes no difference to how many of them there are. It is the premise
of this whole exercise, measured: **300 baud already carries everything the game produces.**
300 baud is the exception, and in the other direction: it moved **29.6 B/s**, which is the 8N1 ceiling
of a 300-baud line to within a byte, and counted three times as many `$55` lead bytes per window as
any faster rate (871 against 253). So the slowest rate is the one working hardest. *Why* the frame
count goes up when the line slows down was not traced; the honest statement is that it did, with zero
receive errors and zero ring overflows while it happened.
| Rate | clean window, A -> B / B -> A | `$55` lead bytes | per-second range | phases seen (A / B) |
|---|---|---|---|---|
| 300 | 2228 B = 29.64 B/s / 2235 B = 29.74 B/s | 871 / 781 | 26.0-31.0 / 26.7-31.0 | [2,3] / [2,3] |
| 1200 | 729 B = 9.70 B/s / 737 B = 9.81 B/s | 268 / 254 | 5.9-26.0 / 5.9-57.0 | [2,3] / [2,3] |
| 2400 | 675 B = 8.98 B/s / 675 B = 8.98 B/s | 253 / 253 | 5.9-12.0 / 8.9-9.0 | [2,3] / [3] |
| 4800 | 699 B = 9.30 B/s / 697 B = 9.27 B/s | 258 / 261 | 5.9-24.0 / 5.9-23.0 | [3] / [2,3] |
| 9600 | 700 B = 9.31 B/s / 694 B = 9.23 B/s | 258 / 261 | 5.9-15.0 / 5.9-17.0 | [3] / [3] |
| 19200 | 675 B = 8.98 B/s / 675 B = 8.98 B/s | 253 / 253 | 8.9-9.0 / 8.9-9.0 | [2,3] / [3] |
| 38400 | 678 B = 9.02 B/s / 678 B = 9.02 B/s | 254 / 254 | 5.9-12.0 / 5.9-12.0 | [3] / [3] |
`connectionPhase` 2 appearing on one machine is a byte-sync round in progress, and every one of them
was followed by phase 3 in a later sample. At 300 and 1200 both machines were caught in one; at 9600
and 38400 neither ever was.
### 3.2 The errors, both of them
Across the seven rate boots - **1193.8 seconds of measured link time**, 1439.3 s counting the second
38400 boot - there were **two** characters rejected by the receiver and **two** receive-ring
overflows, and they are the same two events: one on each machine, both at **1200 baud**, in the
20-second instrumented window. The unconditional `$E73F`
tracepoint and the conditional `$E505 if x > $13` returned identical counts, which is what says the
character was thrown away because the 20-byte ring was full rather than because its status was bad.
Both machines carried on; neither left the packet phase because of it. That is the same signature
`rate1200Report.md` recorded, at the same rate, and 1200 is still the first rate at which a burst can
outrun the ring.
**No rate above 1200 produced a single receive error of any kind.** On the previous build, 9600 and
19200 produced 8-41 and 74-128 ring overflows in the same 20-second window.
### 3.3 The transmit-side loss is gone as well
VICE writes `Acia1: ACIA: data register written although data has not been sent yet.` into its own log
every time the driver writes the data register before the previous character has left - one
transmitted character lost each time, on emulation and on a real 6551 alike. On the previous build
that scaled with rate: 0.8% of offered characters at 4800, 2.1% at 9600, 2.2-2.3% at 19200.
**Every emulator log in this report contains zero of them**, at every rate, including both 38400
boots. `wedgeDiagnosis.md` section 4 predicted exactly that: it traced the overwrite to a pass of the
NMI handler being interrupted between `lda txCharActive` `$E6C4` and `dec uartPendingCount` `$E6C9`,
so an inner pass fed the chip and the outer pass fed it again. With the guard, no pass is ever
interrupted there.
No emulator log in this report contains `Main CPU: JAM` either.
## 4. Why 38400 no longer wedges, measured three ways
The mechanism is `nmiHandlerState` `$E5BD` and the drain loop at `nmiIdle` `$E6E4`: a second NMI that
finds a pass already running does not run the handler body, it adds one to the byte and returns - and
because it never reads the status register, `/IRQ` stays asserted and no further edge can be raised
until the running pass acknowledges. The running pass decrements the byte and goes round again
whenever the decrement leaves a note behind.
### 4.1 The stack: 200 seconds at 38400, nothing below `$01B4`
`testStack.py`, two machines at 38400, both in the packet phase, four checkpoints armed **before the
link was ever opened** and left armed for a 200-second watch. Run `pcst384b`, which measured its own
speed: **200.88 emulated seconds on A at 100.4% of real time and 200.58 on B at 100.2%.**
| Checkpoint | What it catches | Result |
|---|---|---|
| `trace exec e685 if sp < $b4` | any entry to `commNmiHandler` one frame deeper than the deepest healthy entry ever recorded | **0 hits on A, 0 hits on B** |
| `trace store $0100 $0180` | the stack descending thirteen frames past anything healthy | no hits |
| `break exec e685 if sp < $20` | the stack nearly gone | **never fired** |
| `break store $0100 $0140` | the same, from the other side | **never fired** |
At the end of the watch both machines were running game code (`PC` `$12A4` and `$12E5`) with
**`SP` = `$E9`** and, in the processor status byte, **the interrupt flag CLEAR** (`10100000` and
`00100000`) - so both raster IRQs were still alive and still calling the driver's `$E00C` tick. Page
1 from `$0100` upwards holds nothing but the flat `$00`/`$FF` pattern that was in it before the link
opened; there are no handler frames in it below `$01D0` at all.
An earlier boot with the same instrument, `pcst384`, gave the same result - 0 deep entries, no break,
`SP` `$E9` on both - but it predates the speed check added to the script, so its 200 seconds are not
independently proven to be real-time seconds. It is counted as a boot in section 4.4 and nothing else
rests on it.
On the previous build the same instrument caught three runaways in two boots, each one taking the
stack pointer from about `$B3` to `$00`-`$08` in **8.4, 11.5 and 12.5 milliseconds**, and one machine
was stopped mid-descent with 34 complete nested frames still decodable off page 1. The wedged
machines in `wedgeDiagnosis.md` section 5 were found with the interrupt flag **set** in 124 readings
out of 124, which is the state the two readings above are the opposite of.
### 4.2 The counter: it is entered, it never nests here, and it always comes back to zero
`testGuard.py` puts a tracepoint on the handler's entry `$E685` and one on `nmiLeaveNote` `$E6F0`,
the instruction a nested pass executes instead of running the body:
| Run | Rate | Window | Handler entries | Nested | `nmiHandlerState` after | `linkErrorCount` |
|---|---|---|---|---|---|---|
| `pcgd384c` A | 38400 | 91.00 emulated s (**101.1%** of real time) | 1632 | **0** | `$00` | 0 |
| `pcgd384c` B | 38400 | 90.70 emulated s (**100.7%**) | 1620 | **0** | `$00` | 0 |
| `pcgd384b` A | 38400 | 10.80 emulated s (132.7%) | 175 | **0** | `$00` | 0 |
| `pcgd384b` B | 38400 | 8.81 emulated s (108.3%) | 144 | **0** | `$00` | 0 |
| `pcgd192b` A | 19200 | 9.08 emulated s (111.2%) | 156 | **0** | `$00` | 0 |
| `pcgd192b` B | 19200 | 8.78 emulated s (107.6%) | 144 | **0** | `$00` | 0 |
Eighteen handler entries a second at both rates, which is what nine bytes a second in each direction
should produce - one `RDRF` and one `TDRE` per character. Both machines were in `connectionPhase` 3
at both edges of every window, and `nmiHandlerState` `$E5BD` read `$00` every time it was read.
The 90-second window is worth reading closely, because it says how close the margin actually is.
Every trace line carries the emulator's cycle counter, so the gap between consecutive entries can be
measured directly:
| | A | B |
|---|---|---|
| entries | 1632 | 1620 |
| gaps below 300 cycles | **817** | **1027** |
| median of those gaps | **266 cycles** | **266 cycles** |
| smallest gap seen | 224 | 222 |
| gaps below 178 cycles (one cheap pass) | **0** | **0** |
| stack pointer at entry | min `$D9`, max `$F4` | min `$D7`, max `$F0` |
**266 cycles is exactly one character time at 38400**, and half of all the gaps are that value, so
the interrupts really do arrive back to back at the rate `wedgeDiagnosis.md` priced - a burst of
consecutive characters, each one asking for its own interrupt. Against them stands a handler that
costs 175 cycles for a single half and 256-266 for a pass that services both. **The margin is
between zero and ninety cycles**, and in these 3252 entries it was never lost: no gap fell below 178,
nothing nested, and the stack pointer stayed within one frame of the main line.
That is the real shape of the fix. 38400 was never dangerous because it is fast; it was dangerous
because losing that race once corrupted `uartPendingCount` and the byte in the transmitter, which
produced a resync flood, which produced the saturation, which produced the runaway. The guard makes
losing the race cost a six-byte note instead of a nested pass, so the first link in that chain is
gone.
(The two eight-second windows read 108-133% of real time. They open immediately after a monitor
pause and VICE runs fast for a moment afterwards; the 90-second window and every window in section 3
do not show it. Nothing in this table depends on wall-clock speed anyway - entries, nesting, gaps and
the guard byte are all counted in the emulated domain.)
### 4.3 The accidental stress test, and what it is worth
The lost run described in section 2 - the one that ran eight times real time - is the only
measurement in this session in which the guard was actually **used**, and it is worth keeping for
that reason. Why it was harder on the driver was not chased down; the obvious candidate is that two
emulators running unlimited do not run at the same multiplier, so each one's bytes arrive in the
other's emulated time frame in bursts that true speed never produces. Whatever the reason, the
numbers below are the numbers, and they are the ones the guard exists for:
| Machine | Entries to `$E685` | Nested (`$E6F0`) | Emulated span | Entry rate | SP at entry | Gap between entries |
|---|---:|---:|---|---:|---|---|
| A | 3050 | **260 (8.5%)** | 62.30 s | 49/s | min `$D0`, max `$F0` | min **58 cycles**, median 540 * |
| B | 1685 | **145 (8.6%)** | 38.95 s | 43/s | min `$D6`, max `$F4` | min **58 cycles**, median 271 * |
\* median taken over the gaps below 100000 cycles, i.e. excluding the quiet stretches between bursts.
Read the two things that matter. **Second NMIs really do arrive inside a running pass** - a 58-cycle
gap against a handler that costs 175 to 266 cycles is not a near miss - and there were 405 of them.
**The stack pointer at entry never went below `$D0`**, one frame below the interrupted level and 128
bytes above the `$B4` line that the previous build crossed on its way to zero. Both machines ended
the window in the packet phase with `linkErrorCount` = 0 and `nmiHandlerState` = `$00`.
On the previous build every one of those 405 nested entries would have been a full re-entrant pass
with a status read in it.
### 4.4 Seven boots at 38400, no wedge
| Boot | What it did | Outcome |
|---|---|---|
| `pc384` | `testHighRates.py`, 170.4 s of measured windows | healthy, phase 3 in all 24 ring samples |
| `pc384b` | `testHighRates.py`, 245.5 s of measured windows | healthy, phase 3 in all 24 ring samples |
| `pcst384b` | `testStack.py`, 200.9 s at 100.4% of real time | no deep entry, no break, `I` clear at the end |
| `pcst384` | `testStack.py`, the same watch before the speed check was added | no deep entry, no break |
| `pcgd384` | `testGuard.py`, the unlimited run of section 4.3 | 405 nested passes absorbed, still linked |
| `pcgd384b` | `testGuard.py`, 8 s at true speed | 319 entries, none nested |
| `pcgd384c` | `testGuard.py`, 90 s at true speed | 3252 entries, none nested, both still in phase 3 |
The previous build wedged **four boots out of four**, every one inside the first minute, one of them
ending with `*** Main CPU: JAM at $0007`.
## 5. Adopt mode, two machines, before and after
`testAdoptPair.py`. Both machines are booted to the "PRESS A OR O" prompt with the opponent module
resident - which is the last moment at which the chip has not been touched, confirmed by
`aciaPageLatch` `$E5BF` = `$00` and the adopt entry `$E726` = `$00` on both machines - and then the
6551's control register is written from the monitor, exactly as a C64 Ultimate or a
previously-configured cartridge would have left it. Then the link is opened and measured.
### 5.1 Two stop bits at 2400 baud, and even parity in the command register
Preset: control `$98`, command `$69`.
```
$DE03 $DE02 baudIndex bitPeriodLo adoptSlot pageLatch
A at the prompt $00 $00 $15 $50 $00 $00
A after preset $98 $69 $15 $50 $00 $00
A link opened $98 $05 $15 $98 $98 $DE
A in phase 3 $98 $09 $15 $98 $98 $DE
A after 75 s $98 $09 $15 $98 $98 $DE
B at the prompt $00 $00 $15 $50 $00 $00
B after preset $98 $69 $15 $50 $00 $00
B link opened $98 $09 $15 $98 $98 $DE
B in phase 3 $98 $05 $15 $98 $98 $DE
B after 75 s $98 $09 $15 $98 $98 $DE
```
`$98` is 2400 baud, 8 data bits, **two stop bits**, internal generator - a byte the driver's own table
does not contain and would never have chosen. **It came through untouched.** `baudIndex` stayed at
`$15` = 21, the adopt entry (the disk default, so nothing had to be pressed); `aciaProbeAdopted`
`$E655` parked the adopted byte in `baudEntryTable`'s eighth entry at `$E726`; `loadBaudParameters`
wrote it to the chip and kept the live copy in `bitPeriodLo` `$E056`.
And the link works on it: **75.20 s, 677 and 675 bytes = 9.00 and 8.98 B/s, not one of the 148
one-second sampling intervals carrying nothing** (5.9-12.0 B/s one way, 8.9-9.0 the other), both
machines in `connectionPhase` 3, A and B running at 101.0% and 100.6% of real time.
The command register is the other half of that dump and section 8 is about it.
### 5.2 A note on `baudIndex` `$E055` = `$15`
Two different `$15`s meet in these dumps and it is worth separating them once. `baudIndex` `$15` is
**21 decimal**, the byte offset of the eighth entry of `baudEntryTable` - the adopt entry. Control
register `$15` is 300 baud, 8N1, internal generator. They are unrelated, and section 7 has both of
them in the same dump at the same time.
## 6. The eight-data-bit guard
Preset: control `$B8` = 2400 baud, **7 data bits**, two stop bits, internal generator.
```
$DE03 baudIndex bitPeriodLo adoptSlot pageLatch
A after preset $B8 $15 $50 $00 $00
A link opened $98 $15 $98 $98 $DE
B after preset $B8 $15 $50 $00 $00
B link opened $98 $15 $98 $98 $DE
```
`$B8` -> `$98`: **bits 6-5 went from `%01` (7 data bits) to `%00` (8 data bits), and nothing else in
the byte moved.** Bit 7 (the second stop bit) survived, bit 4 (the internal clock source) survived,
and bits 3-0 (2400 baud) survived. That is the `and #$9F` at `$E665` inside `aciaProbeAdopted`, and
it is the only place in the module where a control byte is masked - the seven fixed entries are
constants that already have those bits clear. Link: **75.15 s, 675 bytes each way = 8.98 B/s,
8.9-9.0 B/s in every one of the 148 one-second intervals, none of them empty, both machines in phase
3.**
The single-machine table in `testAdopt.py` covers the other word lengths on the same build, 46 of 46
checks passing: `$5A` (six data bits at 4800) -> `$1A`, `$3C` (seven data bits at 9600) -> `$1C`,
`$9F` (38400 with two stop bits) -> `$9F` unchanged, `$18` -> `$18` unchanged.
### Why eight data bits are mandatory
This is a statement about the source, not a measurement, and it cannot be measured on this rig -
VICE's ACIA is byte level, so it will not truncate a character to seven bits no matter what the
control register says. The argument does not need a measurement, because one instruction decides it:
```
awaitPeerSyncFf:
...
jsr receiveByteTrackZeros ; E84C look for the peer's answer
tax ; E84F
beq linkTickReturn ; E850 nothing yet
inx ; E852 was it $FF?
bne restartByteSync ; E853 anything else: start the sync over
sta ackPending ; E855 $FF received, both ends are byte aligned
```
The byte-sync handshake is `$00` one way and **`$FF`** the other, and `$FF` is tested for exactly, by
incrementing it to zero. On a seven-bit line the peer's `$FF` arrives as `$7F`, `INX` makes `$80`,
the branch is taken, and the sync restarts - for ever. The link would never leave
`connectionPhase` 2: red border, never green, which is the symptom `README.md` section 4 describes.
Two more places in the frame layer need the eighth bit and would corrupt data rather than stall it:
`computePacketChecksum` `$E937` is a full eight-bit rotate-and-add (`ASL A` / `ADC #$00` / `ADC
packetBuffer,x`), and the length code the frame layer leads with is `$55`, with the sequence bytes
that follow it taking any value at all. **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 at `$E665`
touches two bits and no others.
## 7. The unusable-value fallback
Preset: control `$00`, on both machines - what a 6551 reads after reset, and what VICE's cold ACIA
reads on this rig without any help (see the "at the prompt" rows in section 5.1, which read `$00`
before anything was written).
```
$DE03 baudIndex bitPeriodLo adoptSlot pageLatch
A after preset $00 $15 $50 $00 $00
A link opened $15 $15 $15 $15 $DE
B after preset $00 $15 $50 $00 $00
B link opened $15 $15 $15 $15 $DE
```
`(control AND $1F) < $11` means bit 4 = 0 - an external receiver clock a SwiftLink does not have - or
rate bits `%0000`, an external transmit clock, or both. `$00` is both. The driver replaced it with
`$15`: 300 baud, 8N1, internal generator.
**It did not hang.** Both machines opened the link and carried traffic for the full window: 75.16 s,
769 and 768 bytes = 10.23 and 10.22 B/s, **0 of 148 one-second intervals empty**, `linkErrorCount` =
0 in all 24 samples, `uartRxCount` never above 1.
The per-second range is the independent check that the rate really did change: **7.9 to 30.0 B/s one
way and 6.9 to 30.0 the other**, where every window at 1200 baud and above sat between 5.9 and 12.0.
30.0 B/s is the 8N1 ceiling of a 300-baud line, so the fallback is not just a byte in a register - the
wire is running at it. `connectionPhase` oscillated between 2 and 3 on both machines during the
sampling, which is what 300 baud does on this rig (the dedicated 300-baud rate run in section 3 shows
the same pattern on both machines), and traffic never stopped while it did.
The single-machine table adds the two other unusable shapes on the same build: `$0C` (9600 baud but
bit 4 clear) -> `$15`, and `$10` (internal clock but no rate bits at all) -> `$15`.
## 8. What adopt mode does not cover: parity, and the command register
In section 5.1 the command register was pre-set to `$69` - DTR on, receiver interrupt on, **parity
enabled, even** - alongside the control register. After the link opened it read `$05` and then
`$09`. The parity bits were cleared.
That is not a bug in the adopt path; it is a consequence of where a 6551 keeps parity. Bits 7-5 of
the **command** register select it, and the driver writes that register from constants at four sites:
`$E546` and `$E5A4` write `$09` (DTR on, RTS low, receiver IRQ on, transmit IRQ off), `$E5A9` writes
`$0B` (the same with the receiver IRQ off, for the disk-load suspend), and `$E63F` writes `$05` (the
transmit interrupt armed). Every one of those has bits 7-5 = `%000` = parity disabled. The one
write that is not a constant - the `C= + H` DTR toggle at `$E327`, which EORs bit 0 of
`aciaCommandShadow` - only ever carries forward what those constants left there. And the register
cannot be part-written: DTR, RTS and both interrupt enables live in the same byte, so arming the
transmitter necessarily rewrites the parity bits too.
**Consequences, stated plainly.**
* A port configured for parity will run without it. Both ends of a *Modem Wars* link run this same
driver and both clear those bits identically, so the two agree and the link works - which is what
section 5.1 measured with `$69` pre-set on both machines.
* What it would break is a link to something that is *not* this driver and does expect parity. There
is no such peer: the only other implementation is the stock user-port driver, which is 8N1 only.
* Stop bits are adopted and parity is not, so `spec_uart.md` hazard 8's "same 8N1 framing" is now only
true of the *data* bits. Two SwiftLink ends will agree on whatever the control register said; a
stock user-port peer can only ever be 8N1, so an adopted `$98` will not talk to one.
Making parity adoptable would mean keeping the user's bits 7-5 in a shadow and OR-ing them into all
four command writes. That was not done and was not measured; it is recorded here as the honest edge
of what "adopt" currently means.
## 9. No regression
### 9.1 Both straps still detect and latch
`testStrap.py`, one machine, watchpoints on `$DE00-$DE03`, `$DF00-$DF03` and both register-mirror
ranges armed before the module can touch anything.
**`$DE00` strap** - the cartridge answers on the default page:
```
load $DE03 LDA $DE03 at $E56B A=$00 aciaProbeAdopted reads the user's setting FIRST
store $DE03 STA $DE03 at $E567 A=$1E probe pattern 1
load $DE03 LDA $DE03 at $E56B A=$1E it matches
store $DE03 STA $DE03 at $E567 A=$15 probe pattern 2
load $DE03 LDA $DE03 at $E56B A=$15 it matches too - latch = $DE
store $DE03 STA $DE03 at $E567 A=$15 loadBaudParameters writes the adopted byte ($00 was
load $DE03 LDA $DE03 at $E56B A=$15 unusable, so the $15 fallback)
load $DE00 LDA $DE00 at $E55F A=$00 drain the receiver
load $DE01 LDA $DE01 at $E558 A=$50 status
store $DE02 STA $DE02 at $E554 A=$09 command
```
**`$DF00` strap** - `SWIFTLINK_ACIA_BASE=0xDF00`, nothing at `$DE00`:
```
load $DE03 LDA $DE03 at $E56B A=$F6 the adopt read on the default page: open bus
store $DE03 STA $DE03 at $E567 A=$1E probe pattern 1
load $DE03 LDA $DE03 at $E56B A=$F6 not $1E - this page is not a 6551
load $DF03 LDA $DF03 at $E56B A=$00 the fallback reads the REAL chip's setting
store $DF03 STA $DF03 at $E567 A=$1E probe pattern 1 at $DF00
load $DF03 LDA $DF03 at $E56B A=$1E it matches
store $DF03 STA $DF03 at $E567 A=$15 probe pattern 2
load $DF03 LDA $DF03 at $E56B A=$15 it matches too - latch = $DF
store $DF03 STA $DF03 at $E567 A=$15 loadBaudParameters, adopted $15 again
```
That second block is the one adopt mode changed: the `$DF00` fallback now **reads** the page before it
writes to it, so a user whose cartridge is strapped to `$DF00` gets their own settings adopted from
their own chip and not from the open bus at `$DE00`. The `$F6` the `$DE00` read returned is a value
`probeOpenBus.py` recorded in half its samples of an empty `$DE03`; it is briefly parked in the adopt
slot and then overwritten by the `$DF00` read, which is the whole reason the fallback calls the same
routine instead of only aiming at the page.
On both straps:
| | `$DE00` run | `$DF00` run |
|---|---|---|
| all six accessors' operand high bytes (`$E556`, `$E55A`, `$E561`, `$E565`, `$E569`, `$E56D`) | `$DE` | `$DF` |
| **`aciaPageLatch` `$E5BF`** | **`$DE`** | **`$DF`** |
| the cartridge page read back after the link opened | `$DE00-$DE03` = `00 10 05 15` | `$DF00-$DF03` = `00 10 09 15` |
| the *other* page read back | `$DF00-$DF03` = `F6 F6 F6 F6` (open bus) | `$DE00-$DE03` = `F6 F6 F6 F6` |
| the NMI vector `$FFFA` | `$E685` | `$E685` |
| accesses to the mirror ranges `$DE04-$DEFF` / `$DF04-$DFFF` | **0** | **0** |
| stops in a further 45 s with only the mirror tripwires armed | **0** | **0** |
| the game reached the modem prompts and opened a link | yes | yes |
One byte in those dumps needs a word of warning, because it looks alarming and is not:
`nmiHandlerState` `$E5BD` read `$01` in both strap runs. Every watchpoint in this test is on an ACIA
register, and almost every ACIA register access this driver makes is made from inside
`commNmiHandler` - so the emulator was stopped *inside a pass*, which is exactly when that byte is
supposed to be 1. The reading that says the guard byte rests at zero is the one in section 4.2,
taken with the machine stopped by nothing but a monitor pause.
### 9.2 1200 baud, two machines, still healthy
The 1200-baud row of section 3 is that run: `testHighRates.py`, one boot, both machines on **C= + 1**,
170.3 s of measured link time across three windows, both machines at 100.5-103.5% of real time.
729 and 737 bytes in the clean window (9.70 and 9.81 B/s), **0 of 334 sampling intervals carrying
nothing**, two byte-sync rounds entered on A and six on B with three completions each, and the two
receive-ring overflows of section 3.2 - one per machine - recovered from. `uartRxCount` reached 14 on
B and never the 20-byte limit. Both machines were found in `connectionPhase` 3 in most of the 24
samples and in phase 2 in the rest, which is a byte-sync round in progress; every one of them was
followed by a phase-3 reading.
## 10. What this does not show
* **VICE, not hardware.** No physical SwiftLink, no real cable, and no C64 Ultimate - the device this
whole exercise is about has still never been touched by any of it.
* **VICE's ACIA is byte level.** It paces whole characters at the rate the control register asks for
but does not shift them bit by bit, so **no framing or parity error can be produced on this rig at
all**, and a wrong word length cannot corrupt a character here. Two consequences: "zero characters
rejected at every rate above 1200" is a statement about this emulation, and the eight-data-bit
argument in section 6 is a reading of the source rather than an experiment.
* **Adopt mode is verified against VICE's control register, not against a device's idea of one.**
Everything in sections 5 to 7 assumes the hardware *presents* its configuration in the 6551 control
register the way VICE and a real SwiftLink do. If an Ultimate presents `$00` there while actually
running at some other rate, this driver will adopt 300 baud and be wrong, and nothing here can tell
you whether it does.
* **The traffic is the menu, not a game.** Both machines reach the GAME TYPE menu and stay there, as
in every earlier report in this directory. Nine bytes a second in each direction is what that
produces. A running battle may produce more, and the rates were not measured under it.
* **38400 is healthy at this traffic, not at any traffic.** Section 4.2 is explicit: at nine bytes a
second the handler is entered eighteen times a second and, in 3252 entries, never nested - but half
of those entries were one character time apart, so the margin measured there is between zero and
ninety cycles. 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. What the guard changes is what happens then: the receive ring overflows and the ARQ layer resynchronises, instead of the stack eating page
1. That degradation path was exercised at 1200 in this report and at 9600/19200 on the previous
build; it was never provoked at 38400 here, because nothing in this game saturates a 38400 line.
* **One boot per rate**, except 38400, which got seven. 170 seconds is enough to say a link keeps
moving; it is not enough to say it always will.
* **Parity is not adopted** (section 8), and no measurement here says what a device that really is
configured for parity would do with a driver that turns it off.
* **The `uartPendingCount` `$E0A5` repair probe never ran**, because `testHighRates.py` only runs it
when a direction carries zero bytes for a whole window, and no direction ever did.
## 11. Files
| File | What it is |
|---|---|
| `testHighRates.py` | the rate sweep: hot-key rate selection, clean and instrumented windows, ring sampling, the wedge dump |
| `testAdoptPair.py` | **new**: two machines, a pre-programmed control (and optionally command) register, before/after dumps and a measured window |
| `testAdopt.py` | the single-machine preset table, the 38400 hot key and `C= + U` |
| `testGuard.py` | entries to `commNmiHandler` and how many of them were nested; now forces warp off with both machines stopped and brackets its window with the cycle counters |
| `testStack.py` | the deep-nesting tracepoint and the stack-page checkpoints; same warp and speed fix |
| `testStrap.py` | both straps, with watchpoints on both cartridge pages and their mirrors |
| `testLogs/portCompat300.txt` ... `portCompat38400.txt` | the seven rate runs |
| `testLogs/portCompat38400run2.txt` | the second 38400 boot, four windows, 245.5 s |
| `testLogs/portCompatAdopt98.txt`, `...AdoptB8.txt`, `...Adopt00.txt` | the three two-machine adopt runs |
| `testLogs/portCompatAdoptTable.txt` | the single-machine table, 46 of 46 checks |
| `testLogs/portCompatStack38400b.txt` | the 200-second stack watch, with its own speed reading |
| `testLogs/portCompatStack38400.txt` | the same watch, one boot earlier |
| `testLogs/portCompatGuard38400b.txt`, `...Guard19200b.txt`, `...Guard38400c.txt` | the guard counts at true speed |
| `testLogs/portCompatGuard38400.txt`, `testLogs/stack.pcgd384.[AB].deep.txt` | the unlimited-speed run of section 4.3 and its raw trace |
| `testLogs/stack.pcgd384c.[AB].deep.txt` | the 90-second trace behind the inter-entry gap table in section 4.2 |
| `testLogs/portCompatStrapDe00.txt`, `...StrapDf00.txt` | the two strap runs |
| `testLogs/highRate.pc*.samples.txt` / `.trace.txt` / `.ring.txt` | per-second byte counts, raw tracepoint output and the ring samples behind section 3 |
| `shots/hrpc*.png`, `shots/apap*.png`, `shots/pcadopt*.png`, `shots/pcstrapD*.png`, `shots/stpcst384*.png` | both machines at every stage of every run |