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

1096 lines
81 KiB
Markdown

# Testing the SwiftLink opponent module in VICE
> **Note on addresses.** Sections 0 to 9 record runs made before the probe-hardening pass, which
> relocated six internal helpers, and before the adopt-mode and re-entrancy-guard pass, which moved
> five more. Addresses quoted in those sections are the ones those routines had at the time of the
> run. Sections 10 and 11 quote the addresses of the builds they were run against - the pre-guard
> build in section 10, the current one in section 11. See `ADDRESS_CHANGES.md` for both mappings.
What was tested: `../Modem_Wars_SwiftLink.d64`, the stock game disk with the $E000 opponent module
replaced by the 6551/SwiftLink build (`swiftlink/build/swiftlinkDriverE000.bin`). Everything below
was observed in VICE 3.7.1 (`x64sc`) - sections 0 to 6 on 2026-08-21, the fix in section 5 and
sections 7 to 9 on 2026-08-22, against a disk rebuilt with `./build.sh` (`checkAbi.py`: 452 of 4096
bytes changed in 24 regions, `ABI CHECK PASSED`). Nothing is inferred from the source alone unless
it says so.
Scripts used, all in `swiftlink/`:
| script | what it does |
|---|---|
| `viceHarness.py` | launches `x64sc` on a private Xvfb display with a private config file, drives the remote monitor, sends real key events |
| `testBoot.py` | boots a disk, photographs the options menu, dumps $E000 |
| `testModemSelect.py` | boots, picks COMPETE WITH MODEM OPPONENT, answers the modem prompts, records every access to $DE00-$DE03 |
| `testTwoMachines.py` | two instances with their serial lines cross-connected through a TCP relay |
| `testRealtime.py` | two instances at **true C64 speed**, windowed byte counting, cycle-counter check, error tracepoint, hot-key rate selection |
| `testHotkeys.py` | one instance at true C64 speed: which host key is the Commodore key, C= + C, the border paint, forced-DCD traces, baud hot keys |
| `testStrap.py`, `testStrapPair.py` | the `$DF00` strap: one machine with watchpoints on both cartridge pages and both mirror ranges, then two machines with the mirror tripwire armed |
| `test1200AutoBaud.py` | one machine against a fake Hayes modem that answers `CONNECT 1200`, then `$E7B2` driven directly |
| `testHighRates.py` | 4800 to 38400 and a deliberate rate mismatch: per-machine rate selection, clean and instrumented windows, `uartRxCount` sampling, a wedge dump and a `uartPendingCount` repair probe |
| `testCost.py` | prices and classifies every pass of the NMI handler by pairing its entry with its exits, and prints the per-character-time budget |
| `testWedge.py` | pairs handler entries with exits, counts re-entries, measures the duty cycle, counts the `$E00C` frame service, and profiles PC/SP |
| `testStack.py` | the deep-nesting tracepoint `trace exec e685 if sp < $b4` and the stack-page tripwires; `traceonly` mode records the staircase without ever stopping the machine |
| `testGuard.py` | counts entries to `commNmiHandler` against the ones the re-entrancy guard turned away |
| `testAdopt.py` | one machine: control-register presets written from the monitor and read back out of the driver's choices, plus the 38400 hot key and C= + U |
| `testAdoptPair.py` | the same with two machines: control (and command) register pre-set on both, before/after dumps and a measured window |
| `scanIoAccess.py`, `probeBankOut.py`, `probeOpenBus.py` | static scan of every instruction in the game that can reach `$DE00-$DFFF`, VICE's I/O map, and what an empty `$DE00` reads back |
| `probeInput.py`, `probeFire.py`, `probeModemPrompt.py` | small experiments used to work out how to drive the game headlessly |
Logs kept: `testLogs/aciaAccessLog.txt` (800 monitor stops on $DE00-$DE03),
`testLogs/twoMachineRun.txt` (the whole two-machine session), `testLogs/realtimeRun300first.txt` and
`testLogs/realtimeRun2400first.txt` with their per-second `testLogs/realtime.*.samples.txt`,
`testLogs/hotkeyRun.txt` and `testLogs/hotkeyKeymapSweep.txt`, `testLogs/fixRun1..3*.txt`,
`testLogs/strapDf00*.txt` and `strapSelectDf00*.txt`, `testLogs/rate1200Run1..3.txt` and
`autoBaud1200Run1..4.txt`, `testLogs/highRate*.txt`, the wedge-diagnosis logs
`testLogs/stack.st384*.txt`, `stack.st192a.txt`, `wedge.wg384a*.txt` and `cost.c192a*.txt`, and the
current build's `testLogs/portCompat*.txt` with their `stack.pc*.deep.txt` traces.
Sections 1-4 all ran in warp. Sections 5 to 11 ran at true C64 speed, with the qualifications each
section states; how that was finally managed is the first entry under "Problems and loose ends".
Seven companion reports hold the full detail: `realtimeReport.md`, `hotkeyReport.md`,
`strapReport.md`, `rate1200Report.md`, `highRateReport.md`, `wedgeDiagnosis.md` (section 10) and
`portCompatReport.md` (section 11). `hardenReport.md` covers the probe-hardening pass between them.
**Sections 10 and 11 supersede parts of section 9, and of `highRateReport.md` and `hardenReport.md`.**
Section 9's "38400 wedges, four boots out of four" was a measurement of a build whose NMI handler could
be re-entered without bound; section 10 establishes that mechanism and section 11 measures the build
that fixes it, in which 38400 is selectable and did not wedge in seven boots. The older sections and
reports are left exactly as they were taken - they are records of runs made against the build of the
day - and carry pointer notes instead.
Because the machine is shared, every run picks its own X display number, its own TCP ports and its
own VICE config file (`-config <scratch>/vicerc.<label>.<pid> +saveres`), so the user's
`~/.config/vice/vicerc` is never read or written and no fixed port or display is claimed. Only
processes started by these scripts were ever killed.
## 0. The SwiftLink baud doubling - verified
The task said to check the claim that a SwiftLink's 3.6864 MHz crystal doubles every rate in the
6551's table, before relying on it. Two independent sources agree with it, so **the claim holds**:
* cc65's SwiftLink serial driver (`libsrc/c64/ser/c64-swlink.s`) maps control-register values to
rates as $02=150, $05=300, $06=600, $07=1200, $08=2400, $09=3600, $0A=4800, $0B=7200, $0C=9600,
$0E=19200, $0F=38400 - exactly twice the data sheet's nominal rate for each code.
* VICE's own ACIA emulation does the doubling explicitly. `vice/src/aciacore.c` holds
`acia_bps_table[16] = {10, 50, 75, 109.92, 134.58, 150, 300, 600, 1200, 1800, 2400, 3600, 4800,
7200, 9600, 19200}` with the comment "swiftlink and turbo232 modes use the same table except they
double the values", and `get_acia_bps()` returns `acia_bps_table[ctrl & 0x0F] * 2` for
`ACIA_MODE_SWIFTLINK`.
So the module's table at $E6FD - $15, $17, $18, $1A, $1C, $1E, $1F for 300, 1200, 2400, 4800, 9600,
19200 and 38400 - is right, and it is right *in VICE too*, which matters because VICE paces the
emulated line at the rate the control register asks for.
## 1. The patched disk boots exactly like the stock disk
Command line (the ACIA arguments are the same in every run):
x64sc -config <private> +saveres -remotemonitor -remotemonitoraddress 127.0.0.1:<free port>
-drive8truedrive -drive8type 1541 -sounddev dummy +sound -warp -jamaction 2
-autostart-warp -model ntsc -joydev2 1
-acia1 -acia1mode 1 -acia1base 0xDE00 -acia1irq 1 -myaciadev 0
-rsdev1 127.0.0.1:<free port> -rsdev1baud 2400
-autostart <disk.d64>
Both disks were booted this way, breaking at $0800 to know when the loader had finished. (Sections 5
and 6 use the same line with `+warp` instead of `-warp`, and warp the loader through from the monitor
instead - see the first loose end.)
* `shots/stockMenu.png` - the stock `Modem_Wars_1988_Electronic_Arts_b4.d64`.
* `shots/swiftlinkMenu.png` - the patched `Modem_Wars_SwiftLink.d64`.
The two are the same screen: the STATS / REPAIR / MISC tab bar and the OPTIONS panel listing COMPETE
WITH MODEM OPPONENT / PRACTICE WITH SOLO TRAINER / WATCH GAME FILM / SAVE GAME FILM / LOAD GAME FILM.
No crash, no hang, no missing text. (The highlight bar blinks, so it is present in some screenshots
and not in others; the selected row was read out of $91D5 instead of judged from the picture.)
At this point $E000 still holds the **solo trainer** module (`4C 43 E0 4C 60 E0 ...`) on both disks -
the game only fetches the modem build when the player asks for a modem game.
## 2. COMPETE WITH MODEM OPPONENT reaches the modem screens
Driving the menu needed two fixes that are worth recording for anyone repeating this:
* VICE's GTK front end ignores synthetic `XSendEvent` key presses, which is what
`xdotool key --window <id>` sends. Keys only arrive through XTEST, i.e. `xdotool key` with no
`--window`, after `xdotool windowfocus --sync`. (There is no window manager on the private
display, so `windowactivate` cannot work - it needs `_NET_ACTIVE_WINDOW`.)
* VICE's numpad joystick uses **KP_0** as the fire button (`kbd_initialize_numpad_joykeys` in
`arch/gtk3/kbd.c`), not KP_5. With KP_8 held, zp_5D went $0F -> $0E and the menu row $91D5 went
1 -> 0; with KP_0 held, joyFireUp (zp_5F) went 1 -> 0.
With that, selecting row 0 produces the sequence the disassembly predicts:
1. The game reloads the $E000 module from track 18 sector 7 + track 34 and cold-initialises it.
Memory afterwards: `$E000: 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. $E540-$E56F held
`AE 55 E0 20 53 E3 A9 09 20 51 E5 20 5F E5 4C 58 E5 | 8D BE E5 8D 02 DE 60 | AD 01 DE 8D BC E5 60
| AD 00 DE 60 | 8D 00 DE 60 | 8D 03 DE 60 | AD 03 DE 60`, i.e. `configureUserPortLines` followed
by the six one-instruction ACIA accessors, all aimed at page $DE. The stock module has CIA2
writes (`A9 00 8D 05 DD ...`) at that address.
$E012 read `4C B7 0D` and $E015 `4C EC E2`, so the game had patched the module's keyboard hook to
its own `scanKeyboard` and keys really do pass through the driver.
2. "GET OPPONENT ON PHONE AND / PRESS A OR O AND SET MODEM." -
`shots/modemPromptAnswerOrOriginate.png`.
3. Answering with A (or O) moves on to "PRESS SPACE, WAIT, HANGUP PHONE." -
`shots/modemPromptPressSpace.png`. This is `openCommLink` at $1B69 in the main program; the
module's $E003 entry is only called after the space bar.
Nothing hung and nothing crashed at any of those steps. Note that **no ACIA register is touched
until both prompts have been answered** - watchpoints on $DE00-$DE03 stayed silent through the whole
menu, the module load and both prompts, which is exactly right.
## 3. The module really programs the 6551
With `watch store $de00 $de03` and `watch load $de00 $de03` armed from the options menu onwards, and
the two prompts answered with A and then space, the very first accesses to the cartridge were these
(the monitor prints the instruction and the accumulator at each stop; the full 800-stop log is in
`swiftlink/testLogs/aciaAccessLog.txt`):
```
1 $E567 STA $DE03 A=$1E aciaProbe: write the probe pattern to the control register
2 $E56B LDA $DE03 A=$1E aciaProbe: it reads back - a 6551 answered at $DE00
3 $E567 STA $DE03 A=$15 loadBaudParameters: control = 8N1, internal clock, rate bits $5
4 $E55F LDA $DE00 A=$00 aciaResumeReceiver: drain whatever the receiver had latched
5 $E558 LDA $DE01 A=$50 aciaGetStatus: seed the status shadow, clear the IRQ flag
6 $E554 STA $DE02 A=$09 setCiaNmiMask: command = DTR on, RTS low, RX IRQ on, TX IRQ off
7 $E567 STA $DE03 A=$1E configureUserPortLines -> loadBaudParameters -> aciaDetect again
8 $E56B LDA $DE03 A=$1E
9 $E567 STA $DE03 A=$15 control = $15 again
10 $E554 STA $DE02 A=$09 command = $09
11 $E55F LDA $DE00 A=$00 drain the receiver
12 $E558 LDA $DE01 A=$10 read the status (TDRE set, no IRQ pending)
```
**The values written, and whether they are the intended ones:**
* **Control register $DE03 = $15.** Bit 4 = 1 is the internal baud-rate generator; bits 6-5 = 00 is
8 data bits; bit 7 = 0 is one stop bit; bits 3-0 = $5. On a SwiftLink that rate code is 300 baud
(150 doubled), which is what `baudIndex` ($E055 = 0) selects, and 300 baud is what the stock
bit-banged driver defaults to - so the two builds are still wire compatible at their default
speed. This matches the intent exactly: 8N1, internal clock.
* **Command register $DE02 = $09.** Bit 0 = 1 DTR asserted; bit 1 = 0 receiver interrupt *enabled*;
bits 3-2 = 10 which on a 6551 means RTS low (asserted) with the transmit interrupt off; bit 4 = 0
no echo; bits 7-5 = 000 parity disabled. Again exactly the documented intent - "DTR on, RTS low,
receiver IRQ on, transmit IRQ off" - and it agrees with the source comment at $E546.
* **$DE02 = $05** appears as soon as there is something to send: the same byte with bits 3-2 = 01,
i.e. the transmit interrupt armed. It goes back to $09 the moment the transmit ring runs dry.
That is `startNextTxChar` / the NMI transmitter behaving as designed.
* **$DE03 = $1E** is not a baud setting; it is `aciaProbe`'s test pattern, written and read back to
find out whether a cartridge is there. `loadBaudParameters` overwrites it with the real value two
instructions later, so the ACIA is never left holding it.
* Status reads returned $50 on the first read, $10 on the second and $90 on every read made from
inside the NMI handler. $90 = IRQ flag + TDRE, the handler acknowledging its own interrupt. DCD
(bit 5) read as 0 in all three, i.e. carrier present - a monitor peek at $DE01 before the link was
opened had shown $30, with DCD set. So in VICE the emulated carrier comes up as soon as the ACIA
is opened, and that is what lets the driver walk past the Hayes dialogue on its own.
**And it transmits.** The bytes handed to the data register at $E563 were, in order:
```
$0D $41 $54 $51 $30 $56 $31 $58 $31 $41 $0D = CR "ATQ0V1X1A" CR
```
which is `modemAnswerInitString` ($EBE3) - quiet off, verbose on, extended result codes, answer -
the Hayes string `openCommLink` queues for answer mode. After the modem answer timer expired the
driver moved into connection phase 2 and started the byte-sync handshake: the remaining 191 captured
data-register writes were all $00, which is `beginByteSyncPhase` sending $00 until the peer answers
$00. The far end of the TCP line in that run was a logging sink that never answers, so it kept
sending them - exactly what the protocol says it should do.
That is the whole answer to "does it really talk to the ACIA": it probes it, programs control and
command, drives the transmitter from the NMI, and reaches the game's own link handshake.
Caveat about this particular capture: I stopped that run once it had collected 800 watchpoint stops,
so its own serial-side byte log went with the process. The byte-level proof that the traffic really
leaves the emulator is in section 4.
## 4. Two machines over TCP - they play each other
This one worked, and it went further than "they exchange bytes".
VICE's `-rsdev1 "host:port"` makes the emulator a TCP **client**, so two instances cannot be aimed at
each other directly. `testTwoMachines.py` therefore runs a small relay: it listens on one free port,
accepts both emulators' connections, forwards every byte from one to the other and logs both
directions. That is a null modem cable made out of sockets - it adds no protocol of its own, so
anything the two C64s agree on they agreed on themselves. Both instances got the same ACIA settings
as above (`-acia1mode 1`, base `$DE00`, NMI).
Sequence: boot both, pick COMPETE WITH MODEM OPPONENT on both, answer the A/O prompt with **A** on
machine A and **O** on machine B, then space on both. The relay log then shows, in order:
1. `[relay] connection 1` when A's driver opened its ACIA, `[relay] connection 2` when B's did. Both
scripts confirmed the open by reading `aciaCommandShadow` ($E5BE) back as `$05`.
2. Traffic in both directions, beginning with the `$00 ... $00 $FF` pattern of
`beginByteSyncPhase`, then `$55`-led frames. Early sample, machine A to machine B:
`55 EE 55 | 55 0A 70 | 55 EE 55 | 00 00 ... 00 FF | 55 EE 55 | 55 0A 70 | 55 0E 74 | 55 EF 56 ...`
and B to A the same shape with different sequence bytes - this slice starts mid-frame -
`... ED 54 | 55 09 6F | 55 05 6B | 55 05 6B | 55 EE 55 | 55 0E 74 ...`.
Later, once a game was being negotiated, longer frames appear: `59 08 8C 05 F7` repeated.
3. Totals kept climbing throughout: 2676/2040 bytes at the first checkpoint, 9085/10100 at the
second, 12818/10442 at the third.
Both drivers reached **connection phase 3** - `runPacketPhase`, the game's own packet protocol - and
stayed around phases 2 and 3, dropping back to a re-sync now and then and recovering:
[A] carrierOverrideFlags = 0, connectionPhase = 3
[A] carrierOverrideFlags = 0, connectionPhase = 2
[A] carrierOverrideFlags = 0, connectionPhase = 3
[B] carrierOverrideFlags = 0, connectionPhase = 3
And the game layer above it worked too. `shots/two02aLinkOpen.png` shows machine A displaying
"OPPONENT PICKING GAME/MAP." - message $1A, which `startGameSession` pins on side 1 while it waits
for side 0's map seed - and `shots/two02bLinkOpen.png` shows machine B on the GAME TYPE menu
(SCRIMAGE / QB SNEAK / THE BOMB / FACE-OFF / SLUGGERS / FULL WAR / DEFENDER). So the two machines
had already agreed over the SwiftLink link which of them plays side 0 and which side 1, and side 0
had moved into the setup overlay. That is the game's own handshake running end to end over a 6551.
Final register and state readback, taken through the monitor with both links up:
| | machine A | machine B |
|---|---|---|
| `$DE02` command | `$09` | `$09` (shadow `$E5BE`) |
| `$DE03` control | `$15` | - |
| `$DE01` status | `$10` (TDRE, DCD low = carrier) | `$98` in `aciaStatusSave` |
| `$E03B` isLinkActive | `$01` | `$01` |
| `$E03C/$E03D` link status | `$F8/$F8` (carrier + data flowing) | `$F8/$F8` |
| `$E040` connectionPhase | `$02` | `$03` |
| `$E047` linkErrorCount | `$00` | `$00` |
| `$E033` ciaIcrShadow | `$7F` | `$7F` |
That last row is worth calling out: `swiftlink/DESIGN_NOTES.md` requires `ciaIcrShadow` to be pinned
at `$7F` at run time, because the main program writes that byte straight back into CIA2's interrupt
mask after every film-buffer access and this driver wants no CIA2 interrupt at all. Its stored value
in the module image is `$90`; both running machines read back `$7F`, so `setCiaNmiMask` really does
correct it.
**What did not work:** the C= + C carrier-override hot key. `forceCarrier()` sent `ctrl+c` four
times to each machine and `carrierOverrideFlags` ($E04A) read back `0` every time, so I cannot say
that hot key works - either VICE's default keymap does not put the Commodore key on left Ctrl, or the
key never reached the driver's hot-key layer. It turned out not to be needed: VICE reports DCD
asserted as soon as the ACIA is opened, so the drivers walked past the Hayes dialogue and into byte
sync on their own. Shortly after those keystrokes both machines put up "PICK UP PHONE THEN PRESS
SPACE." (message $17, the reconnect prompt) - `shots/two04aLater.png` and `shots/two04bLater.png` -
so something in that key sequence did disturb the session, but I did not chase down what.
**That is now explained, and the hot key does work** - see section 6. The first of the two guesses
above was the right one: VICE's default keymap puts the Commodore key on **Tab**, and `Control_L` is
the C64 CTRL key, which the game's own `scanKeyboard` reports as "no key at all", so `ctrl+c` could
never have reached the hot-key layer no matter how often it was sent.
The full run log is `swiftlink/testLogs/twoMachineRun.txt`.
## 5. Real time instead of warp: 300 baud is healthy; 2400 wedged, was fixed, and now holds
Warp can be switched off (see the first loose end below), so the two-machine test was repeated with
both emulators at **1 MHz** for everything after the loader. Script `testRealtime.py`, two boots,
four 75-second measurement windows during which no monitor input was sent to either machine, byte
counts taken from the relay's own thread. Full detail, including the per-second samples, is in
`realtimeReport.md`.
Each window's emulated speed was checked independently with the monitor's `stopwatch` cycle counter
against wall clock: every window came out at **100.5% to 100.9% of real time**, so neither emulator
was falling behind. (The systematic +0.5-0.9% is the rig - the counter is read while the machine is
stopped and the wall clock starts after it is resumed.)
**At 300 baud the link is healthy and the throughput is exactly the line rate.** While the two games
had something to say to each other - the side-0/side-1 negotiation of section 4 - **almost every
one-second bucket held exactly 30 bytes in each direction**, from t=2 s to t=55 s; over those 54.09
seconds the averages were 29.14 bytes/s A->B and 29.43 B->A. 8N1 costs ten bits per byte, so 30
bytes/s is 300 bit/s: 100% of what 300 baud can carry, in both directions at once. The
window as a whole was 1796 bytes A->B and 1813 B->A in 75.13 s; the tail is quieter because the link
dropped into a byte-sync round. 697 `$55` frame lead bytes from A and 626 from B in that window, so
this is the real ARQ frame layer, not a sync flood. Errors: `linkErrorCount` `$E047` read `$00` at
both ends and a non-stopping tracepoint on `countLinkError` `$E403` recorded **zero hits** for the
whole window. This also confirms VICE paces the emulated line from the ACIA control register and not
from `-rsdev1baud` (which said 2400 throughout).
**At 2400 baud, before the fix, the link came up and then wedged.** Both machines reach connection phase 3 at 2400,
whether the rate is set with C= + 2 on a live link (run A) or at the "PRESS A OR O" prompt before the
link is opened (run B, the order `README.md` tells the player to use), and both times it collapsed
within about twenty seconds and stayed collapsed for three consecutive 75-second windows. The
collapsed state is one-sided: one machine sends **0 bytes in 225 seconds** while its peer pours out
`$00` byte-sync characters at 59.8 bytes/s - exactly one per video frame, which is the game's
once-per-raster-IRQ link tick, not the line rate. No character-level error was counted at either end
while this happened.
The stalled machine is stuck, not idle: `txCharActive` `$E5BB` = `$01` with `aciaCommandShadow`
`$E5BE` = `$09` (transmit interrupt off) and `uartPendingCount` `$E0A5` frozen at `$03`. That pair
is a state the driver cannot leave - `startNextTxChar` returns early while `txCharActive` is set, and
the NMI's transmit half tests the command shadow before it will clear it. **Tested directly:**
writing a single `$00` over `$E5BB` from the monitor and touching nothing else made the machine that
had sent nothing for 225 seconds send 37 bytes in the next 15, and its peer answered with 98. How
the driver gets into that state was **not** determined; `realtimeReport.md` section 4 lists the two
candidate paths (both in the interrupt path, both consistent with the failure appearing at 2400 and
not at 300) and does not pick between them.
**That was fixed on 2026-08-22, and the fix was re-measured on the same rig.** `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. Three sites now hold that invariant:
`aciaSetCommandIdle` `$E72A`, called from the NMI's disarm at `$E6E0`, which writes the command
register and clears the flag with it and closes the one path that was traced; `healStuckTransmitter`
`$E057`, now the first thing `startNextTxChar` `$E607` does, which clears the flag on sight once a
frame whenever the command shadow says the transmit interrupt is disarmed; and `aciaSetControlIdle`
`$E04C`, called from `loadBaudParameters`, so that a speed hot key landing on a character in flight
cannot strand it. The two helpers came out of the seventeen dead stock-UART constants at
`$E04C-$E054` and `$E057-$E05E`; adding those two ranges, plus `$E607` where `startNextTxChar` now
calls `healStuckTransmitter` instead of loading the flag directly, is why `checkAbi.py` reports 452
changed bytes in 24 regions where the pre-fix build reported 434 in 22. The other two changed call
sites, `$E35C` and `$E6DE`, are inside ranges the SwiftLink build had already replaced. Nothing in
the packet layer, the ring buffers, the modem state machine or the frame/ARQ layer moved.
The re-measurement, in `realtimeReport.md` section 8: **nine 75-second windows at 2400 baud over
three boots, 675 seconds of measured link time, and both directions carried traffic in every single
second of every one of them.** Both ways of reaching the rate were used, C= + 2 on a live link and
C= + 2 at the A/O prompt. Across 66 state readings at the window edges the pair that defined the
fault - `txCharActive` `$E5BB` set while `aciaCommandShadow` `$E5BE` bit 2 is clear - **never
occurred**, only the two self-consistent pairs `$00`/`$09` and `$01`/`$05` did, and the script's
deadlock probe (which would have written `$00` over `$E5BB` and said so) never fired, so nothing in
those numbers is the rig repairing the driver. Errors: `linkErrorCount` `$E047` read `$00` at every
edge and the `$E403` tracepoint recorded zero hits in each of the seven windows where it was tallied.
The 300-baud windows in the same runs came back at 29.81 / 29.82 bytes/s, and a run that dropped from
2400 back to 300 with C= + 3 on a live link survived the change and kept playing - which the pre-fix
run had not.
What the fix does **not** claim: the 2400-baud windows were quiet ones, about nine bytes a second, so
the transmitter's arm/disarm path was exercised a few thousand times but the line was never loaded.
The pre-fix fault appeared in exactly this game state and in windows measured the same way, which is
what makes the comparison fair, but a saturated 2400-baud link was never produced, because nothing in
this game state produces one. And the fix is proven by absence over 675 seconds at a rate that used
to fail within twenty, not by a proof that no path into the state remains -
`healStuckTransmitter` is what makes any remaining path self-healing within one video frame rather
than fatal.
One more measured number worth keeping: **one byte per video frame, 59.8 bytes/s, is the ceiling this
driver's link layer can offer** no matter how fast the line is. That is the measured form of what
`README.md` section 3 says in prose about latency rather than throughput.
## 6. The Commodore-key hot keys, C= + C included
Script `testHotkeys.py`, one machine at true C64 speed against a TCP sink that accepts the connection
and never answers. Full detail in `hotkeyReport.md`.
**Which host key is the Commodore key was settled by experiment**, by holding each candidate together
with `C` for 900 ms and reading `carrierOverrideFlags` `$E04A` (only C= + C touches it) and
`lastHotkeyCode` `$E046` while the keys were still down:
| held with `C` | `$E04A` before -> after | `$E046` while held | verdict |
|---|---|---|---|
| `Control_L` | `$00` -> `$00` | `$FF` | nothing reaches the module at all |
| **`Tab`** | `$00` -> `$40` | `$C3` | **C= + C fired: this is the Commodore key** |
| `Super_L` | `$40` -> `$40` | `$C3` | the letter arrives as a plain key; no hot key |
Reproduced from three separate boots, and it agrees with the emulator: the monitor reports
`KeymapIndex=0` with `KeymapSymFile="gtk3_sym.vkm"`, and that file has `Tab 7 5 8200` (row 7 column 5,
"left cbm") and `Control_L 7 2 16392` (the C64 CTRL key). Only the *positional* map puts C= on
`Control_L`. The `Super_L` row is the control case: it is in neither keymap, so the `C` arrives
alone, `$E046` becomes `$C3` - and `$E04A` does not move, because `$E31F cpx #$80` fails. "The key
got in" and "the hot key fired" are two different observations.
**C= + C toggles the override on and off** - `$E04A` `$00` -> `$40` -> `$00`, `$40` being
`carrierOverrideToggleMask` `$E04B` - and it works before the link is open as well as on a live link.
With the link up, the bytes the rest of the game reads change with it and change back:
| state | `$E03C` linkStatus | `$E03D` linkStatusSample |
|---|---|---|
| override off | `$78` (a real carrier sample) | `$78` |
| override on | `$40` (an override sample) | `$40` |
| override off again | `$78` | `$78` |
**And it does what it exists for.** VICE's emulated 6551 never sets status bit 6 - the bit a
SwiftLink puts DCD on - so "no carrier" was injected: break at `$E5E9`, poke `aciaStatusSave` `$E5BC`
to `$40` (DCD deasserted), single-step the carrier sampler. With the override **on** the `BIT
aciaStatusSave` at `$E5F1` is never executed and `$E03D` is published as `$40` = carrier; with the
override **off** and the same forced input, the DCD test runs, `$E03D` is published as `$00` = no
carrier, and the game would tear the session down. Identical input, opposite results, so the hot key
is doing exactly the job section 3 of the README claims for it.
**A baud hot key really reprograms the ACIA.** On the live link: C= + 2 put `baudIndex` `$E055` = 6,
`bitPeriodLo` `$E056` = `$18` and `$DE03` = `$18` (2400 baud on the doubled clock); C= + 3 put all
three back to 0 / `$15` / `$15`. `$18` is a control byte no earlier test in this project had ever
written - section 3 above recorded only `$1E` and `$15`.
**The border indicator paints.** A checkpoint on the module's own `STA VIC_BORDER` at `$E30C`, armed
first and Tab pressed afterwards, fired with `A` = `$02` (red - `$04` from `linkStatusBorderTable`
`$E05F` index 5, halved by the `LSR` at `$E305`) and `X` = `$80`, `scanKeyboard`'s "Commodore key is
held" flag. Reading `$D020` from the monitor is not how to see this: it reads the game's own `$FC`
almost always, because the game rewrites the border later in the same frame. What it looks like on
screen with the key held was not captured.
Still unpressed at the time of this section: C= + H, C= + P, C= + A, C= + O, C= + RETURN, and the
C= + 1 / 4 / 9 / 0 / 8 baud keys. The five baud keys have since all been pressed - sections 8 and 9
- and every one of them programmed the chip on the first press. The other five hot keys have still
never been pressed.
## 7. The `$DF00` strap option
Full detail in `strapReport.md`; scripts `testStrap.py`, `testStrapPair.py`, `scanIoAccess.py`,
`probeBankOut.py`, `probeOpenBus.py`. A SwiftLink has a jumper that moves its 6551 from I/O1
(`$DE00`) to I/O2 (`$DF00`), and a C64 Ultimate's emulation can be configured either way. The module
probes `$DE00` and falls back to `$DF00`, but every run in sections 0 to 6 answered at `$DE00` on the
first try, so **the second half of `aciaDetect` had never executed**.
The rig learned the strap rather than having it hard-coded: `viceHarness.py` gained
`ACIA_BASE = int(os.environ.get("SWIFTLINK_ACIA_BASE", "0xDE00"), 16)`, and `testBoot.py`,
`testModemSelect.py` and `testRealtime.py` build their monitor commands from it. **The default is
unchanged**, so every command line in the earlier sections still means what it meant.
* **The fallback is taken.** With `-acia1base 0xDF00` and watchpoints armed on `$DE00-$DE03`,
`$DF00-$DF03` and both mirror ranges before the module could touch anything, the first stops are
`STA $DE03` A=`$1E`, `LDA $DE03` reading back something that is not `$1E`, then `STA $DF03`,
`LDA $DF00`, `LDA $DF01`, `STA $DF02` - the probe writing its pattern to the first page, failing to
read it back, re-aiming and carrying on at the second. **Every one of 796 subsequent register
accesses landed on `$DF00-$DF03`.** All six accessor operands read `$DF` at run time and the module
image on disk still says `$DE`.
* **The game behaves identically.** Same options menu, same module load, same "PICK UP PHONE THEN
PRESS SPACE." prompt, same `$E000` jump table, same Hayes string on the wire.
* **Two machines at `$DF00` link and carry real ARQ frames.** Both reached connection phase 3. At
true C64 speed 300 baud gave 29.62 and 28.99 bytes/s with 599 and 682 `$55` frame lead bytes and
zero link errors, and 2400 baud gave 8.98 bytes/s each way with no transmitter deadlock. A warp run
reached the side-0/side-1 negotiation with a setup-overlay disk load over the live link.
* **Nothing else in the game touches the page.** Two static passes over every image the C64 loads
find exactly one instruction that can reach `$DF00-$DFFF`, and it is the stock driver's
`sta $DF59,y`, which this build does not contain. The mirror tripwire on `$DE04-$DEFF` and
`$DF04-$DFFF` never fired in any run.
Two things about the probe are worth knowing and neither is a regression. Its read of an empty
`$DE00` is **open bus and not deterministic** - `probeOpenBus.py` sampled `$DE03` 120 times across the
frame and got `$F6` 62 times, `$2C` 32, `$2D` 20 and `$2E` 6, never the `$1E` that would make the
probe believe a cartridge is there, but the value is screen data the driver does not control. And
the probe **re-runs on every link open and every baud hot key**, because `aciaDetect` is the first
thing `loadBaudParameters` does, so a `$DF00` machine re-decides the page on a live link every time
the rate changes, and writes `$1E` into `$DE03` - a foreign device's register - each time. Also,
when the probe does fall back it does not verify `$DF00` at all. `strapReport.md` section 8 lists
three cheap hardenings. Caveat on the tripwire: **VICE maps only the four bytes `$DF00-$DF03`** and
does not emulate the register mirrors a real cartridge has, so the tripwire proves that no instruction
*addresses* the mirror range, not what a real cartridge would do if one did.
## 8. 1200 baud, and the `CONNECT 1200` auto-baud path
Full detail in `rate1200Report.md`; scripts `testRealtime.py` and the new `test1200AutoBaud.py`.
1200 is the rate most likely to matter in practice - the other speed the stock user-port module can
produce, and the one a real Hayes modem's `CONNECT 1200` puts the driver on by itself - and until this
run it had never been driven at all.
**It works.** Both machines took C= + 1 at the A/O prompt, first press, and the ACIA control register
read `$17` on both before the link was ever opened. Six 75-second windows over three boots, 452
seconds of measured link time, 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; 119 is 99% of
what 1200 baud can carry in a second, so the line does saturate in bursts even though the six-window
average is about a quarter of the ceiling. What sets the average is the game, not the wire.
**The transmitter never parked in the old deadlock state.** Thirty state readings at the window
edges, only the two self-consistent pairs, and the deadlock probe never fired.
**1200 is the first rate at which the 20-byte receive ring was seen to overflow.** Thirty-two
overflows across the six windows. Telling an overflow from a bad character needed a second,
conditional tracepoint, because `linkErrorCount` counts both and `restartByteSync` clears it:
```
trace exec $e403 # every character the receiver threw away
trace exec $e505 if x > $13 # ... and the subset thrown away because the ring was full
```
`$E505` is the `CPX #$14` inside `pushUartRxRing` and X held `uartRxCount` three cycles earlier, so
`X > $13` means the ring already held all twenty. In the four windows where both were live the two
counts were **equal in every window** - 8 and 8, 3 and 3, 7 and 7, 1 and 1 - so not one character was
rejected for a bad status; the other thirteen carry the same fingerprint. **The link resynchronised
and carried on every time**: the longest `$00` flood in 452 seconds was 87 bytes, under a second of
line time, and `connectionPhase` was back at 2 or 3 at every edge with `isLinkActive` = `$01`. That
is the ceiling the task brief suspected, and it is real at 1200, not only at the top of the table -
but what the driver does about it is degrade and recover, not wedge.
**The auto-baud path really does select 1200.** A fake Hayes modem in the test script answered the
module's `ATQ0V1X1A` with `\r\nCONNECT 1200\r`; 2.58 s later the byte-sync flood started, and the
machine was left with `$DE03` = `$17` and `bitPeriodLo` = `$17` while `baudIndex` `$E055` was still
**0** - which only `switchTo1200Baud` `$E7B2` can produce, since `openCommLink` would have written
`$15` and a hot key would have left `$E055` = 3. Driving `$E7B2` directly with X = 0, 3 and 6 gave
`$15`, `$17` and `$18`, so the three-byte stride of `baudEntryTable` behaves and not just the one
index. Two side effects showed up there: `txCharActive` is cleared by the new
`aciaSetControlIdle`, so a `CONNECT 1200` arriving mid-character cannot strand the flag; and
`baudIndex` is **not** updated, which is faithful to the stock driver but means a link torn down and
re-opened after a `CONNECT 1200` comes back at **300 baud, silently** (a re-open on a live link,
C= + RETURN, keeps 1200 because it skips `loadBaudParameters` entirely).
Every overflow in all three runs was on machine B and machine A recorded none in 452 seconds. That
is not explained, and it is not the game screen or one emulator falling behind - both held 100.5% of
real time or better throughout.
## 9. The high rates: 4800, 9600, 19200, 38400, and a rate mismatch
> **Superseded in part by sections 10 and 11.** Everything below was measured on the build whose NMI
> handler could be re-entered without bound. Section 10 explains the 38400 wedges recorded here and
> section 11 re-measures every rate on the build with the re-entrancy guard, where 9600 and 19200
> produce no receive errors at all and 38400 is selectable and does not wedge. The numbers below are
> left as they were taken.
Full detail in `highRateReport.md`; script `testHighRates.py`, which reuses `testRealtime.py` and adds
`uartRxCount` sampling, byte-sync entry/completion tracepoints, a wedge dump and a `uartPendingCount`
repair probe. The question is hardware compatibility, not speed: whether the driver survives a line
faster than the game was written for. Every window ran at 100.5% of real time or better on both
machines.
| Rate | Hot key | Control | Both reached phase 3 | Intervals carrying nothing | Ring overflows | Transmit chars lost | Outcome |
|---|---|---|---|---|---|---|---|
| 4800 | C= + 4 | `$1A` | yes | **none**, 0 of 450 | **0** in 20 s of tracing | 0.8% / 1.5% | **healthy** |
| 9600 | C= + 9 | `$1C` | yes | **none**, 0 of 442 | 8 and 41 in 20.6 s | 2.1% / 2.1% | **degrades, recovers** |
| 19200 | C= + 0 | `$1E` | yes | **none**, 0 of 450 | 128 and 74 in 20.4 s | 2.2% / 2.3% | **degrades, recovers** |
| 38400 | C= + 8 | `$1F` | 3 of 4 boots, briefly | **every interval**, one or both ways, for the whole 170-246 s of every boot | 1259 in 21 s on the stuck machine | - | **wedges, 4 of 4 boots** |
| 38400 vs 300 | - | `$1F` / `$15` | yes | **none**, 0 of 474 | **0** | 0.0% / 0.1% | **healthy** |
**What limits the driver, arithmetically.** The raw receive ring holds 20 characters, only the
once-per-frame tick drains it, `awaitWholePacket` will not copy a packet body out until the whole
payload and check byte are resident - up to 17 bytes - so the headroom is about three bytes, and there
is no RTS throttling. A video frame is 0.50 character times at 300 baud, 2.01 at 1200, 4.01 at 2400,
8.02 at 4800, 16.05 at 9600, 32.09 at 19200 and **64.19 at 38400**. Two figures in the existing
documents were wrong: the brief for this work said "roughly 640 character times" at 38400 and
`spec_uart.md` hazard 2 says "about 200"; it is 64.2. The ring is about three times too small at
38400, not thirty - which is why 19200, at 32 characters a frame, still works.
**4800 is healthy.** Four windows, 246.5 seconds, none of the 450 sampling intervals empty, and
**zero** hits on both the unconditional `$E403` tracepoint and the conditional ring-full one. The
byte-sync rounds it does run are the packet layer's own `unwindAndResync` paths, not dropped
characters, and they complete: 78 entries / 76 completions on A and 80 / 78 on B in 76 seconds.
`uartRxCount` sampled 24 times per machine peaked at 7 and 5 of 20.
**9600 and 19200 degrade and recover.** No sampling interval at either rate carried nothing. Every
error was a ring overflow - the conditional and unconditional tracepoints returned identical counts at
both rates, so not one character was rejected for a bad status - and every degradation was followed by
a recovery, roughly 1.3 to 1.8 byte-sync rounds a second with two thirds to seven eighths reaching
`sta ackPending` inside the same window. `uartRxCount` never read the 20-byte limit when sampled.
**And they buy nothing:** the `$55` frame-lead counts do not rise with the rate, so the extra line
time goes into `$00` floods rather than into ARQ frames.
**The driver also overwrites its own outgoing characters.** VICE logs `Acia1: ACIA: data register
written although data has not been sent yet.` each time, one transmitted character lost, on emulation
and on a real 6551 alike; the rates are in the table above. Every one is a checksum failure and a
retransmit at the far end, which is a large part of why the resync rate climbs. How the driver comes
to write a busy data register was **not** traced.
**38400 wedges, four boots out of four**, every one within about a minute of the link coming up and
none recovering in the 170 to 246 seconds each was then watched. The four shapes differ: both
machines frozen in phase 3 with both rings full at 20 of 20 and total silence for 245 s; one machine
flooding `$00` at exactly one byte per video frame while the other froze; one machine's game back on
the plain OPTIONS menu; and one that ended `*** Main CPU: JAM at $0007`, the 6502 executing zero page
with `$00` and `$01` overwritten. **It is not the 2400-baud deadlock** - in every wedge
`txCharActive` and `aciaCommandShadow` were self-consistent, the deadlock probe reported `False` at
every edge, and `healStuckTransmitter` had nothing to heal. The byte out of range in the clearest
case is `uartPendingCount` `$E0A5` reading `$FF` with the transmit ring empty, which is exactly
`spec_uart.md` hazard 1's warning; `startNextTxChar` resynchronises that count whenever it runs with
the ring dry, so on that machine the once-per-frame `$E00C` service had stopped being called, and
which of the two stopped first was not determined. The repair probe was run on the boot whose CPU had
jammed, where no write to any driver byte could have worked, so **it has never been run against the
shape it was written for**.
**A 38400-against-300 mismatch was the healthiest run in the whole report.** Both ends reached phase
3, none of 474 sampling intervals carried nothing, not one ring overflow or bad character at either
end, neither machine wedged, and the lowest resync rate of any pair measured. The pair ran at the
slow end's rate - the slow side pinned at 28.7-29.6 bytes/s, which is 300 baud's 8N1 ceiling to within
a byte - because the packet layer is lock-step and the peer's rate paces the fast end for free. That
must be read with VICE's byte-level ACIA in mind: two emulated ends set to different rates do not
garble each other, so the error storm a real 300-baud receiver would see on a 38400 line cannot be
produced here at all. What the run does establish is that neither state machine falls over on the
mismatch itself.
## 10. Why 38400 wedged: NMI re-entrancy, measured
Full detail in `wedgeDiagnosis.md`; scripts `testStack.py`, `testWedge.py` and `testCost.py`, all run
on **2026-08-22** against the build that still had the fault. **No driver source was changed for any
of it.** Section 9 left the wedge characterised and not explained; this is the explanation, and it is
established rather than inferred.
**The mechanism.** Reading the 6551's status register *is* the interrupt acknowledge, and
`commNmiHandler` reaches that read eight instructions in. From there to the end of the pass - up to
about 220 more cycles - the chip is free to raise a fresh event, and a 6502 does not mask NMI. So the
handler can be re-entered at the very next instruction boundary, at a cost of **6 bytes of stack** (or
8 if the edge lands inside one of the handler's own `JSR`s). At 38400 with traffic both ways there
are two interrupt-raising events per character time - one `RDRF`, one `TDRE` - so an edge arrives
roughly every 133 cycles against a pass that costs 175 to 266. Service time exceeds inter-arrival
time; the queue is not a queue, it is a stack, and it grows without bound.
**The cost of a pass, measured** (`testCost.py`, 19200 baud, four windows, two machines, 34.8 emulated
seconds, **5694 complete passes** paired entry-to-exit and classified by what each pass did):
| what the pass did | passes | whole interrupt |
|---|---:|---:|
| received a character | 2983 | **178 cycles** |
| transmitted a character | 1835 | **178** |
| transmit interrupt found the ring dry, disarmed it | 677 | 175 |
| **received and transmitted in the same pass** | 188 | **256-266** |
| neither half had work (a DCD/DSR change) | 11 | 164 |
A hand count off the source agrees to within a cycle, which matters because it makes the figure a
property of the code rather than of the emulator. Against an NTSC C64's 1,022,727 Hz and 8N1's ten
bits per character, that is 1% of the CPU at 300 baud, 17% at 4800, 33% at 9600, **67% at 19200** and
**134% at 38400** - and 134% is the whole failure in one number. A character time at 38400 is 266.3
cycles; a full-duplex pass costs 266.
**The re-entrancy itself, caught three times** (`testStack.py` with `trace exec e685 if sp < $b4`
armed before the link was ever opened - one frame below the deepest entry any healthy machine has
made, and a tracepoint that prints without stopping the machine):
| run | machine | entries | duration | `SP` fell | frames |
|---|---|---:|---:|---|---:|
| `st384d` | B | 44 | 8611 cycles = **8.42 ms** | `$B0` -> `$02` | 29 |
| `st384d` | B | 91 | 12807 cycles = **12.52 ms** | `$B3` -> `$08` | 28.5 |
| `st384c` | B | 84 | 11780 cycles = **11.52 ms** | `$B3` -> `$00` | 30 |
The gaps between consecutive entries alternate 115 and about 150 cycles, and 115 + 150 = 265 - one
character time at 38400 with two interrupts inside it, measured rather than argued. **Control:** the
same script, the same instrumentation, 200 seconds at **19200** with two machines carrying 16729 and
16856 bytes - six times the traffic the 38400 runs managed - produced **no entry below `SP $B4` at
all** on either machine. At 19200 the handler fits inside the character time, so it never nests.
**The stack, decoded.** `st384b` carried `break exec e685 if sp < $20` as well, and it fired: machine
A stopped at `$E685` with **`SP = $12`**, part-way down, before the pointer wrapped. Its page 1 walks
upward without a gap or an ambiguity into **34 complete nested handler frames plus 16 `JSR` return
addresses**. Saved `$01` was `$35` in all 33 readable frames - the value the handler *forces* on
entry, so a frame that saved it was interrupted by a handler that had already run - saved `X` was
`$14` = 20 in all 33 (the receive ring pinned at its limit because the game had stopped draining it),
and `I` was set in every saved `P`. The interrupted PCs are all in the transmit half, in the window
between the status read and the writes that would remove the interrupt source: `$E6C4` (10), `$E6C7`
(9), `$E551` (8), `$E6C2` (7).
Two things fall out of that table that section 9 could not explain:
* **`$E6C4` and `$E6C7` straddle `dec uartPendingCount $E6C9`.** An outer pass interrupted there has
already read `txCharActive` and decided to count a byte off; the inner pass does its own `dec`, pops
the ring and writes the data register; the outer pass then decrements *again* for the same byte and
writes the data register *again*. That is where section 9's lost transmit characters come from -
VICE's "data register written although data has not been sent yet", 0.8% at 4800 climbing to 2.2% at
19200, the same curve as the duty cycle - and it is how `uartPendingCount` reaches the `$FF` the
38400 dump found. (Shown as a frame stopped one instruction short of the `DEC`; a `DEC`/`INC`
imbalance was not counted during a storm.)
* **`$E551` explains boot 4's impossible command register.** `aciaSetCommand` writes the shadow at
`$E551` and the chip at `$E554`; a machine thrown off the rails between them ends up exactly as
section 9 found it, `$DE02` = `$05` against a shadow saying `$09`.
**Why it never recovers.** `wg384a`'s machine A was watched for 154 seconds after it died.
`pollCarrierState $E3CB` - the `$E00C` entry the raster IRQ calls once a frame - executed **zero**
times in a 12.15-second traced window and zero again in an 8.19-second one, where its healthy peer ran
it 479 times in 8.51 seconds. The monitor read that machine's registers **124 times and found `I` set
in all 124**, `SP` frozen at `$BD`. A 6502 with `I` set takes no IRQ, so there is no frame tick, so
`startNextTxChar`'s once-a-frame repair of `uartPendingCount` (`sta uartPendingCount $E611`, also zero
executions) can never run, and `serviceLock $E0A7` read `$00` - held for ever, because the runaway
threw the CPU out of the middle of a service tick. **That is why the `uartPendingCount` repair probe
could never have worked: by the time a wedged machine can be examined, the driver is fine and the
machine above it is dead.**
**Ruled out**, each on evidence: steady-state CPU starvation (after the wedge the NMI duty cycle is
0.3%); the 20-byte ring being too small (it overflows at 9600 and 19200 too, and those recover; in the
wedges it is full *because* the game stopped draining it); `uartPendingCount` reaching `$FF` (one
wedged machine had `$00`); the control byte `$1F` or the rate table (`$DE03` read `$1F` everywhere and
characters crossed correctly; the 38400-against-300 pair of section 9 ran at `$1F` for 246 s with no
wedge); a spurious interrupt storm from the emulated chip (in normal operation the median gap between
entries is one character time); a protocol deadlock above the driver; and the 2400-baud transmitter
deadlock (`txCharActive`/`aciaCommandShadow` were the self-consistent `$00`/`$09` on every wedged
machine). Framing is irrelevant to this failure: every run was 8N1 throughout.
**38400 did not always kill inside a minute.** Run `st384a` reached phase 3 on both machines and
neither wedged in 200 seconds, on the same build with the same instrumentation. The failure needs both
directions bursting at once, so a quiet link can win the race for a while. Five 38400 boots in that
session, four machines dead.
What this section does not settle: VICE cannot say at what instant a real 6551 re-asserts `/IRQ`, so
the *speed* of the collapse on a real cartridge may differ from 8-13 ms. The direction cannot differ -
356 cycles of work offered per 266 cycles of line time is arithmetic - and on a real line framing and
parity errors would add `reportLinkError` to the receive path, making the pass longer, not shorter.
Logs: `testLogs/stack.st384a.txt` (the boot that did not wedge), `stack.st384b.txt` (the `SP = $12`
stop and the decoded page 1), `stack.st384c.txt` / `st384d.txt` with their `*.B.deep.txt` staircases,
`stack.st192a.txt` (the 19200 control), `wedge.wg384a.txt` and its three traces, and
`cost.c192a.txt` with its four `*.trace.txt` files.
## 11. Whatever the port is set to: adopt mode, the eight-bit guard, and every rate including 38400
Full detail in `portCompatReport.md`; scripts `testHighRates.py`, `testAdoptPair.py`, `testAdopt.py`,
`testGuard.py`, `testStack.py` and `testStrap.py`, run on **2026-08-22** against a disk built from the
current source (`checkAbi.py`: **474 of 4096 bytes changed in 27 regions, `ABI CHECK PASSED`**). This
section is the measurement of two decisions being reversed:
1. **38400 had been made unselectable because it wedged**, which hid a defect instead of fixing it.
Section 10 established the defect; the driver now carries a re-entrancy guard, and 38400 is
selectable again.
2. **The driver imposed its own line settings.** `loadBaudParameters` wrote `$10 OR rateBits` at
every link open - 8N1, internal generator - over whatever the user's hardware was 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 that constraint is a reading of the
source, not a measurement; it is stated with its evidence below.
Every rate window and every adopt window ran with **two machines at true C64 speed** - warp read back
as off through the monitor with both emulators stopped, and every window bracketed by both emulators'
free-running cycle counters, the slowest of which reads 100.5%. A caution that cost one measurement:
the first `testGuard.py` run issued its `warp off` to a *running* emulator and did not read it back,
and produced a trace spanning 62.3 emulated seconds inside an eight-second window. That run is
reported anyway, in 11.3, for what it accidentally measured.
### 11.1 Every rate, both machines on the rate
`testHighRates.py`, one boot per rate, both machines given the rate with the driver's own hot key at
the "PRESS A OR O" prompt, three windows per boot: **clean** (75 s, no tracepoints, so the byte counts
are honest), **sync** (75 s, tracepoints on `beginByteSyncPhase $E805` and `sta ackPending $E855`) and
**errors** (20 s, `countLinkError $E73F` and the conditional `$E505 if x > $13`, which separate "a
character was rejected" from "the 20-byte ring was full").
| Rate | Control | Both reached phase 3 | Intervals carrying nothing | Ring overflows `$E505` | Chars rejected `$E73F` | Max `uartRxCount` A/B | Tx chars lost | Outcome |
|---|---|---|---|---|---|---|---|---|
| 300 | `$15` | yes | **0 of 334** | 0 | 0 | 1 / 1 | 0 | **healthy** |
| 1200 | `$17` | yes | **0 of 334** | 2 | 2 | 1 / 14 | 0 | **healthy**, one overflow each, recovered |
| 2400 | `$18` | yes | **0 of 334** | 0 | 0 | 6 / 0 | 0 | **healthy** |
| 4800 | `$1A` | yes | **0 of 334** | 0 | 0 | 0 / 6 | 0 | **healthy** |
| 9600 | `$1C` | yes | **0 of 334** | 0 | 0 | 0 / 3 | 0 | **healthy** |
| 19200 | `$1E` | yes | **0 of 334** | 0 | 0 | 4 / 6 | 0 | **healthy** |
| 38400 | `$1F` | yes | **0 of 334** | 0 | 0 | 1 / 3 | 0 | **healthy** |
| 38400, boot 2 | `$1F` | yes | **0 of 452** | 0 | 0 | 0 / 1 | 0 | **healthy**, 245.5 s |
1193.8 seconds of measured link time over the seven rate boots, 1439.3 s counting the second 38400
boot. The whole sweep produced **two** rejected characters and **two** ring overflows, and they are
the same two events - one per machine, both at 1200 baud, in the 20-second instrumented window, with
the conditional and unconditional tracepoints returning identical counts, which is what says the
character was thrown away because the ring was full and not because its status was bad. Both machines
carried on. That is the signature `rate1200Report.md` recorded at the same rate; 1200 is still the
first rate at which a burst can outrun the ring.
**No rate above 1200 produced a receive error of any kind**, where on the previous build 9600 and
19200 produced 8-41 and 74-128 ring overflows in the same 20-second window. **And the transmit-side
loss is gone**: every emulator log in this sweep contains **zero** of VICE's `data register written
although data has not been sent yet` lines, at every rate including both 38400 boots, where the
previous build produced them for 0.8% of offered characters at 4800, 2.1% at 9600 and 2.2-2.3% at
19200. Section 10 predicted exactly that, having traced the overwrite to a pass interrupted between
`lda txCharActive $E6C4` and `dec uartPendingCount $E6C9`. No log contains `Main CPU: JAM` either.
**What the traffic actually is, and why it is the point.** Every rate from 1200 up carried about
**nine bytes a second in each direction** and 250-odd `$55` frame-lead bytes per 75-second window.
That is the game, not the line: two machines at the GAME TYPE menu exchanging the lock-step ARQ frames
the protocol keeps alive, and the wire's rate makes no difference to how many there are. **300 baud
already carries everything this game produces** - and 300 is the one rate that works for it, moving
29.6 B/s (the 8N1 ceiling of a 300-baud line to within a byte) and three times as many `$55` lead
bytes as any faster rate, 871 against 253. Why the frame count rises when the line slows was not
traced; what is honest is that it did, with zero receive errors and zero overflows while it happened.
### 11.2 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 that byte and returns - and
because a nested pass **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.
**The stack.** `testStack.py`, two machines at 38400, both in the packet phase, four checkpoints
armed *before the link was ever opened* and left armed for 200 seconds (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 | Result |
|---|---|
| `trace exec e685 if sp < $b4` - any entry one frame deeper than the deepest healthy entry ever recorded | **0 hits on A, 0 hits on B** |
| `trace store $0100 $0180` - the stack 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** |
Both machines ended the watch running game code with `SP = $E9` and the **interrupt flag clear**, so
both raster IRQs were still alive and still calling `$E00C`; page 1 held no handler frames below
`$01D0` at all. On the previous build the same instrument caught three runaways in two boots (section
10) and one machine stopped with 34 nested frames on the stack, found with `I` **set** in 124 readings
out of 124.
**The counter.** `testGuard.py` traces the handler entry `$E685` and `nmiLeaveNote $E6F0`, the
instruction a nested pass executes instead of running the body:
| Run | Rate | Window | Entries | Nested | `nmiHandlerState` after |
|---|---|---|---:|---:|---|
| `pcgd384c` A | 38400 | 91.00 emulated s (**101.1%** of real time) | 1632 | **0** | `$00` |
| `pcgd384c` B | 38400 | 90.70 emulated s (**100.7%**) | 1620 | **0** | `$00` |
| `pcgd384b` A / B | 38400 | 10.80 s (132.7%) / 8.81 s (108.3%) | 175 / 144 | **0** | `$00` |
| `pcgd192b` A / B | 19200 | 9.08 s (111.2%) / 8.78 s (107.6%) | 156 / 144 | **0** | `$00` |
Eighteen handler entries a second at both rates, which is what nine bytes a second each way should
produce - one `RDRF` and one `TDRE` per character. The 90-second window is the one to read closely,
because every trace line carries the cycle counter and so the gaps can be measured: **817 and 1027 of
the gaps were under 300 cycles, with a median of 266 - exactly one character time at 38400** - the
smallest gap seen was 222, and **not one gap fell below 178 cycles, the cost of one cheap pass**. The
stack pointer at entry stayed between `$D7` and `$F4`. So the interrupts do arrive back to back at
the rate section 10 priced, and the margin against them is **between zero and ninety cycles**; in
3252 entries it was never lost.
**Seven boots at 38400, no wedge**: two `testHighRates.py` boots (170.4 s and 245.5 s of measured
windows, phase 3 in all 24 ring samples each), two `testStack.py` watches, and three `testGuard.py`
runs. The previous build wedged four boots out of four, every one inside the first minute, one ending
in `*** Main CPU: JAM at $0007`.
### 11.3 The accidental stress test
The lost run described above - the one that ran about eight times real time - is the only measurement
in the session in which the guard was actually **used**, and it is kept for that reason. Why running
unlimited 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 true speed never produces.
| Machine | Entries `$E685` | Nested `$E6F0` | Emulated span | SP at entry | Smallest gap |
|---|---:|---:|---|---|---:|
| A | 3050 | **260 (8.5%)** | 62.30 s | min `$D0`, max `$F0` | **58 cycles** |
| B | 1685 | **145 (8.6%)** | 38.95 s | min `$D6`, max `$F4` | **58 cycles** |
Two things matter in that table. **Second NMIs really do arrive inside a running pass** - a 58-cycle
gap against a handler costing 175 to 266 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 the previous build crossed on its way to zero. Both machines ended in the packet phase
with `linkErrorCount` = 0 and `nmiHandlerState` = `$00`. On the previous build every one of those 405
entries would have been a full re-entrant pass with a status read in it.
### 11.4 Adopt mode, two machines, before and after
`testAdoptPair.py`. Both machines are booted to the "PRESS A OR O" prompt with the module resident -
the last moment at which the chip has not been touched, confirmed by `aciaPageLatch $E5BF` = `$00` and
the adopt entry `$E726` = `$00` on both - and then the 6551's control register is written from the
monitor, exactly as a configured cartridge would have left it. Then the link is opened and measured.
| Preset | After the link opened | The link |
|---|---|---|
| control `$98` (2400 baud, 8 data bits, **two stop bits**), command `$69` (even parity) | **`$98`** on both machines, adopted whole; `baudIndex` still `$15` = 21 | 75.20 s, 9.00 / 8.98 B/s, **0 of 148** one-second intervals empty, both in phase 3 |
| control `$B8` (2400 baud, **seven data bits**, two stop bits) | **`$98`** on both machines - bits 6-5 corrected and **nothing else in the byte moved** | 75.15 s, 8.98 B/s each way, 0 of 148 empty, both in phase 3 |
| control `$00` (a chip nobody has configured) | **`$15`** on both - 300 baud, 8N1, internal generator | 75.16 s, 10.23 / 10.22 B/s, 0 of 148 empty, `linkErrorCount` 0 in all 24 samples |
`$98` is a byte the driver's own table does not contain and would never have chosen, and it came
through untouched: `aciaProbeAdopted $E655` parked it in `baudEntryTable`'s eighth entry at `$E726`,
`loadBaudParameters` wrote it to the chip, and the live copy sits in `bitPeriodLo $E056`. The old
driver would have written `$15` into all three cases without looking.
The `$00` row is the fallback: `(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, and `$00` is both.
**It did not hang**, and the rate really changed - the per-second range was 7.9-30.0 B/s one way and
6.9-30.0 the other, where every window at 1200 and above sat between 5.9 and 12.0, and 30.0 B/s is the
8N1 ceiling of a 300-baud line.
`testAdopt.py` covers the rest of the table on one machine, **46 of 46 checks passing**: `$18` ->
`$18` and `$9F` (38400 with two stop bits) -> `$9F` copied through untouched; `$5A` (six data bits) ->
`$1A` and `$3C` (seven data bits) -> `$1C`, word length corrected and rate left alone; `$00`, `$0C`
(external receive clock) and `$10` (internal clock, no rate) -> `$15`. 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 both
directions of the switch work. Two different `$15`s meet in these dumps: `baudIndex` `$15` is 21
decimal, the adopt entry's byte offset; control-register `$15` is 300 baud 8N1.
**Adopt mode does not cover parity, and that is a limit rather than a defect.** Parity lives in the
6551's *command* register, and the driver writes that register from constants at four sites - `$09` at
`$E546` and `$E5A4`, `$0B` at `$E5A9` for the disk-load suspend, `$05` at `$E63F` when the transmitter
is armed - every one of which has bits 7-5 = `%000` = parity disabled. The register cannot be
part-written: DTR, RTS and both interrupt enables share the byte. So the `$69` pre-set above read
`$05` and then `$09` after the link opened, and the link ran without parity. Both ends of a *Modem
Wars* link run this driver and clear the same bits, so they agree; the only peer that is not this
driver is the stock user-port one, which is 8N1 only. `spec_uart.md` hazard 8's "same 8N1 framing" is
therefore now true only of the *data* bits.
### 11.5 The eight-data-bit guard, and why it is not optional
`$B8` -> `$98` is the measurement: bits 6-5 went from `%01` (seven data bits) to `%00` (eight), and
bit 7 (the second stop bit), bit 4 (internal clock) and bits 3-0 (2400 baud) all survived. That is
the `and #$9F` at `$E665` inside `aciaProbeAdopted`, the only place in the module where a control byte
is masked.
**Why the mask has to be there is a statement about the source, and it cannot be measured on this
rig** - VICE's ACIA is byte level and will not truncate a character to seven bits however the control
register is set. The argument needs no 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 never leaves `connectionPhase` 2, the
border stays red and never turns green. 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, and the frame layer leads with a `$55` length code followed by sequence bytes that take
any value at all. **Parity and stop bits cost the frame layer nothing**, which is exactly why the
mask touches two bits and no others.
### 11.6 No regression: both straps, and 1200 baud
`testStrap.py`, one machine, watchpoints on `$DE00-$DE03`, `$DF00-$DF03` and both mirror ranges armed
before the module can touch anything. On the **`$DE00`** strap the first cartridge access in the whole
session is now `LDA $DE03` returning `$00` - the adopt read - and only then the two probe patterns
`$1E` and `$15`, both of which read back, latching `$DE`. On the **`$DF00`** strap the adopt read on
the default page returns `$F6` (open bus), the first probe pattern fails there, and the fallback
**reads `$DF03` before it writes to it**, so a cartridge on the other strap gets its own settings
adopted rather than the floating bus's. That is the part adopt mode changed.
On both straps: all six accessor operands and `aciaPageLatch $E5BF` agreed on the right page; the
other page read back `F6 F6 F6 F6`; the NMI vector `$FFFA` read `$E685`; **zero** accesses to the
mirror ranges `$DE04-$DEFF` / `$DF04-$DFFF`, and zero more in a further 45 seconds with only the
mirror tripwires armed; and the game reached the modem prompts and opened a link. One byte in those
dumps looks alarming and is not: `nmiHandlerState $E5BD` read `$01` in both strap runs, because every
watchpoint in that test is on an ACIA register and almost every ACIA access this driver makes is made
from inside `commNmiHandler` - the emulator was stopped *inside a pass*, which is when that byte is
supposed to be 1. The reading that says it rests at zero is the one in 11.2.
1200 baud is the rate a stock user-port peer can also produce, and it is the 1200 row of 11.1: one
boot, both machines on C= + 1, 170.3 s of measured link time, 0 of 334 sampling intervals carrying
nothing, `uartRxCount` reaching 14 on one machine and never the 20-byte limit, and the two ring
overflows recovered from.
### 11.7 What section 11 does not show
* **VICE, not hardware.** No physical SwiftLink, no real cable, no C64 Ultimate.
* **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. "Zero characters rejected at every
rate above 1200" is a statement about this emulation, and 11.5 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.**
Every preset was written there from the monitor. That is the same byte a configured cartridge would
leave, 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. If a device presents
`$00` while actually running at some other rate, this driver adopts 300 baud and is wrong.
* **The traffic is the menu, not a game.** Both machines reach the GAME TYPE menu and stay there, as
in every earlier report here. Nine bytes a second each way is what that produces; a running battle
may produce more, and no rate was measured under one.
* **38400 is healthy at this traffic, not at any traffic.** The margin measured in 11.2 is between
zero and ninety cycles, and the arithmetic of section 10 still says a *saturated* 38400 line costs
more CPU than a C64 has. What the guard changes is what happens then - ring overflow and an ARQ
resync instead of the stack eating page 1 - and that degradation path was exercised at 1200 here and
at 9600/19200 on the previous build, never at 38400 on this one, because nothing in this game
saturates a 38400 line.
* **One boot per rate**, except 38400, which got seven. 170 seconds says a link keeps moving; it does
not say it always will.
* **Parity is not adopted** (11.4), and nothing measures 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.
* **The guard's nested path has only ever been exercised above real time.** The 405 nested passes of
11.3 came from an unlimited-speed run; every speed-checked window counted zero.
Logs: `testLogs/portCompat300.txt` through `portCompat38400.txt` and `portCompat38400run2.txt`;
`portCompatAdopt98.txt`, `...AdoptB8.txt`, `...Adopt00.txt`, `portCompatAdoptTable.txt`;
`portCompatStack38400.txt` / `...b.txt`; `portCompatGuard38400.txt`, `...b.txt`, `...c.txt`,
`portCompatGuard19200.txt` / `...b.txt`; `portCompatStrapDe00.txt` / `...Df00.txt`; the raw traces
`stack.pcgd384*.[AB].deep.txt` and `stack.pcst384*.[AB].deep.txt`; and the per-second samples in
`highRate.pc*.samples.txt`.
## Problems and loose ends
Two defects in the module turned up in the real-time runs, and both are now **fixed and
re-measured**. The first, the 2400-baud transmitter deadlock, is in section 5. The second, the
38400-baud wedge, is in section 9; it was open when that section was written, was explained in section
10 as unbounded NMI re-entrancy in `commNmiHandler`, and section 11 measures the build that guards
against it - so "38400 must not be selected", which the earlier version of this paragraph said, is no
longer the position. Everything else here is a limit of the test rig or something worth knowing
before repeating it.
* **Warp can be turned off after all - just not with a resource.** `resourceset "WarpMode" "0"`,
`resourceset "Warp" "0"` and `resourceset "WarpModeEnabled" "0"` really are all answered with
"Unknown resource" in VICE 3.7.1, which is what the earlier version of this report concluded from.
But **the monitor has a `warp` command**: `warp on`, `warp off`, and `warp` on its own, which
answers `Warp mode is on.` / `Warp mode is off.`. `viceHarness.ViceSession(..., warp=False)`
starts `x64sc` with `+warp`; the test then warps the loader by hand, breaks at `$0800` as every
other test does, and issues `warp off`. Sections 5 and 6 were measured that way, with the
`stopwatch` cycle counter confirming 100.5-100.9% of real time. Sections 1-4 still ran in warp
and still say nothing about real-time behaviour.
Two things fell out of this that anyone repeating the earlier runs needs. **`-warp` on the command
line does not survive the autostart on this disk**: VICE logs `AUTOSTART: aborting.` a second or
two in - the disk's own fast loader leaves the KERNAL routines VICE watches - and `-autostart-warp`
drops warp at that point, so a run that does not warp explicitly crawls; one such run sat over
eleven minutes without reaching the menu, while the same script with an explicit `warp on` reached
the options menu in about 50 seconds, twice in a row. That is very probably the load-time
variation recorded in the next entry. (The `-autostart-warp` half of that is inference from VICE's
log line, not something measured directly.) And `viceHarness.setWarp()`, which `bootPastLoader`
still calls, only knows the three resource spellings, so it returns `None` and changes nothing; the
monitor `warp` command is what does the work, and the tests issue it themselves.
* **Load time varies wildly**, from about 90 seconds to over five and a half minutes of warp for the
same disk on the same machine, depending on what else was running. Two runs were abandoned and
restarted because of it. Anything scripted around this needs generous timeouts.
* **The A/O prompt is easy to miss.** A key pressed before the module has finished loading is
simply lost, and the prompt then waits for ever. My first attempt pressed A once, 25 seconds
after the fire button, and it landed too early - which is why an early run recorded zero ACIA
accesses and looked like a failure. The working version offers A and space repeatedly until the
ACIA is touched. (The tell that the key was taken: `lastTypedKey` $9248 goes back to $FF, because
`waitForAnswerOriginateKey` exits through `resetMessageQueue`.)
* **The options-menu highlight bar blinks**, so a screenshot catches it in either phase; the
selected row was verified by reading $91D5, not by looking at the picture.
* **The Commodore key is Tab, not Ctrl** - which is the whole reason C= + C looked broken in section
4. VICE's live keymap here is the symbolic one (`KeymapIndex=0`, `KeymapSymFile="gtk3_sym.vkm"`),
where `Tab` is row 7 column 5, the Commodore key, and `Control_L` is row 7 column 2, the C64 CTRL
key. Holding left Control is worse than merely not being C=: row 7 column 2 is `$FF` in the game's
own `keyMatrixCodeTable` `$0D67`, so `scanKeyboard` answers "no key at all" for as long as it is
down, which is exactly the `lastHotkeyCode` = `$FF` the sweep in section 6 measured. With Tab the
hot key works. What is *still* untested about it: VICE's emulated 6551 never sets status bit 6, so
the "no carrier" DCD it exists to override had to be poked into `aciaStatusSave` `$E5BC` at a
breakpoint rather than produced by the emulation, and the override was never tried with two
machines actually playing. On real hardware with a real peer dropping DCD it is still unproven.
* **Do not put `Alt_L` in a key sweep, and do not hold a key across a monitor stop.** Holding Alt
opens the GTK menu bar, which takes a keyboard grab; every `xdotool` key after that goes to the
menu and the emulator sees nothing, and the rest of that run is junk. Separately, a key held with
`xdotool keydown` does not survive a monitor stop: the `$E30C` border checkpoint never fired when
Tab was pressed before the breakpoint was armed, and fired immediately when it was armed first and
Tab pressed after the machine was running. Anything that needs a key down *while the CPU runs*
must arm its breakpoints first and press afterwards.
* **A real-time measurement must not touch the monitor while it runs.** Any input stops that
emulator, and `viceHarness.mon()` returns as soon as it sees a prompt, which is often the previous
command's - so a command can be sent into a monitor that has not finished the last one. An early
version of `testRealtime.py` left one emulator sitting at the prompt for a whole 75-second window
(17099 emulated cycles against 75 seconds of wall clock). It now reads to quiescence, verifies
every resume, and throws away any window in which either machine ran under 90% of real time.
* **The socket null modem can in principle drop data.** Its forwarding sockets are non-blocking and
a chunk that cannot be written is discarded rather than queued. At 30-240 bytes/s against a kernel
buffer of tens of kilobytes this should never happen, and the clean 55-second saturated run at 300
baud is evidence that it did not, but it was never instrumented.
* **Two emulators started at once**: the second one can run past $0800 before its breakpoint is set,
in which case the breakpoint never fires and the boot wait burns its full timeout. `viceHarness.py`
now checks the monitor prompt's PC first and skips the wait when the loader is already done.
* **The module-residency trap, which cost four runs across three sessions.** While the 1541 is still
fetching track 34 at true C64 speed - the best part of a minute - `$E000-$EFFF` still holds the
previous overlay, and both of the rig's "is it up yet" tests can read that wreckage as success.
`testTwoMachines.answerModemPrompts` decides the link is open as soon as `$E5BE` is non-zero, and
the wreckage read `E8 84 18 AC` there in three separate runs, so `$AC` was taken for an open link
while the screen still said "WORKING..."; `testRealtime.selectRate` uses "`$E013/$E014` reads
`B7 0D`" as its residency test, and the wreckage read exactly that in another. The fix, in
`test1200AutoBaud.waitForModule` and `testHighRates.moduleResident`, is to wait for **all sixteen
bytes** of the `$E000` jump table; `testStrapPair.py` requires the jump table **and** a programmed
control register. `testTwoMachines.py` and `testRealtime.py` themselves were left alone, so the
trap is still there for anyone reusing them directly.
* **`linkErrorCount` `$E047` cannot be used to count errors.** `restartByteSync` clears it at
`$E814`, so it reads `$00` at a window edge while errors are plainly happening, and at high rates it
simply wraps - across one 38400 boot's four edges it read 208, 21, 94, 206. Worse, it does not
distinguish a bad character from a ring overflow. Both facts are why sections 8 and 9 use
tracepoints instead: `trace exec $e403` for every rejected character and
`trace exec $e505 if x > $13` for the subset rejected because the ring was full. The monitor reads
condition numbers as hex, which is why the constant is written `$13` and not `19`.
Machines are told apart afterwards by the free-running cycle counter each trace line carries - the
two emulators start seconds apart and their stopwatch ranges never overlap.
* **A tracepoint-counting regexp in `testRealtime.py` was wrong, and two runs' own summaries are
false because of it.** The monitor announces a hit as `#1 (Trace exec e403)` and disassembles the
line as `.C:e403 ...` - **no dollar sign on either** - so a pattern of `C:\$e403` matches nothing.
Runs 2 and 3 of the 1200-baud series print "0 tracepoint hits" in their logs and that is not true;
the counts in section 8 come from the saved `*.trace.txt` files, re-counted afterwards.
* **`$DE03` sometimes reads `$00` and `$DE01` `$FF` on a machine that is running perfectly well.**
That is the monitor reading through a CPU that happens to have the I/O area banked out -
`readFilmByte`, `writeFilmByte` and `copyPageUnderIo` set `$01 = $34` for up to 2600 cycles at a
time. Two of the four 38400 boots caught a machine that way at every window edge, which briefly
looked like a driver fault and is not one. Any test that reads the cartridge page through the
monitor should read `$01` alongside it.
* **Instrumented windows are not free and must be kept separate from the ones the throughput numbers
come from.** The `$E403`/`$E505` windows in section 9 consistently read 102-103.5% of real time
against their own cycle counters rather than the usual 100.5-101%, which is pause-and-resume
overhead landing inside a shorter window. A clean window carries no tracepoints at all.
* **Do not rebuild the disk while a measurement is running.** `./build.sh` rewrites the `.d64` the
running emulators have attached; one 1200-baud run was killed and restarted for that reason, and its
emulators and Xvfb displays cleaned up by hand.
* **One monitor socket closed on its own.** Post-fix run 2 ended in a `BrokenPipeError` on machine
B's monitor socket during a state readback, after all three of its 2400-baud windows had completed
and been reported. Nothing in the emulator log explains it. The three windows are used;
`testRealtime.py` now writes each window's samples and trace as the window closes rather than in the
closing summary, and a fourth boot was run to replace what run 2 could not report.
* **Emulator pairs were sometimes run two at a time on the same box** - 4800 and 9600, and 38400 boot
2 with the mismatch run. That is exactly the sort of thing that invalidates a real-time measurement,
and it is recorded for that reason, but it did not: every window in every report holds 100.5% of
real time or better against its own cycle counter. What it did break was the *fixed sleeps* in the
boot sequence, which is where the residency failures above came from.
* **`spec_uart.md` hazard 2's arithmetic is wrong and has not been corrected in that file.** It says
a video frame is "about 200 character times" at 38400 baud; at 3840 characters a second and 59.826
frames a second it is 64.2 (`highRateReport.md` section 1). The conclusion the hazard draws is
unaffected - the 20-byte ring is still outrun - but the size of the problem is three times smaller
than that sentence implies, which is why 19200 survives.