1096 lines
81 KiB
Markdown
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.
|