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

303 lines
18 KiB
Markdown

# The Commodore-key hot keys, exercised in VICE
> **Note on addresses.** This report records a run made before the probe-hardening pass, which
> relocated six internal helpers. Addresses quoted below are the ones those routines had at the
> time of the run. See `ADDRESS_CHANGES.md` for the mapping onto the current source.
`testReport.md` left one hole in the driver's hot-key layer:
> **The C= + C carrier override could not be exercised.** `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.
That is closed here. Everything below was observed on 2026-08-21 with VICE 3.7.1 (`x64sc`, NTSC),
one machine, `../Modem_Wars_SwiftLink.d64`, driven by `swiftlink/testHotkeys.py`. Nothing is
inferred from the source unless it says so. The full session log is
`swiftlink/testLogs/hotkeyRun.txt`.
**Summary of what is now verified:** the Commodore key under VICE's default C64 keymap is **Tab**;
C= + C toggles `carrierOverrideFlags` `$E04A` bit 6 on and off; with the override on the carrier
sampler reports carrier without ever reading DCD, and it does so even when the DCD input says "no
carrier", where with the override off the same input produces "no carrier"; and C= + 2 followed by
C= + 3 reprogram the ACIA control register `$DE03` to `$18` (2400 baud) and back to `$15` (300).
---
## 1. How you press the Commodore key under VICE - determined by experiment
The game does not use the KERNAL. `scanKeyboard` `$0DB7` reads the CIA1 matrix itself, once per
raster IRQ, and it reports the Commodore key only when matrix **row 7, column 5** is pulled low. So
the whole question is which X keysym VICE's active keymap puts at 7/5, and the only way to be sure is
to press candidates and watch a byte the module writes.
`testHotkeys.py` holds each candidate together with `C` and reads two bytes while the keys are still
down: `carrierOverrideFlags` `$E04A`, which only the C= + C hot key touches, and `lastHotkeyCode`
`$E046`, which the module writes on every new key code it sees. Every key is pressed with
`xdotool keydown`, held for 900 ms - about 54 of the game's once-a-frame scans at true C64 speed -
and then released, because a tap is not reliably visible to a once-a-frame scanner.
| host key 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 |
(`Super_L` starts from `$40` because the `Tab` row above had just turned the override on; what
matters in its row is that the value does not change.)
That is reproducible: the same three lines came out of three separate boots.
**Why the earlier `ctrl+c` attempt could never have worked**, and it is not just the tap:
* The monitor reports `KeymapIndex=0` with `KeymapSymFile="gtk3_sym.vkm"` - index 0 is the
**symbolic** map, so `gtk3_sym.vkm` is the live one.
* In that file, `Tab 7 5 8200` (`8192` = "key is (left) cbm on emulated machine") and
`Control_L 7 2 16392` (`16384` = "key is (left) ctrl"). Left Control is the C64 **CTRL** key, not
the Commodore key. Only the *positional* map `gtk3_pos.vkm` puts C= on `Control_L`
(`Control_L 7 5 8200`), and it puts CTRL on Tab.
* Row 7 column 2 - CTRL - is `$FF` in the game's own `keyMatrixCodeTable` `$0D67`, and `scanKeyboard`
answers `$FF` = "no key at all" the moment it finds that cell (`$0E37 bne L_0E3E`). So holding
left Control does not merely fail to be C=, it blinds the whole scan. That is exactly what the
`$E046` = `$FF` column above shows.
The `Super_L` row is the control case worth having: `Super_L` appears in neither `gtk3_sym.vkm` nor
`gtk3_pos.vkm`, so VICE ignores it, the `C` arrives on its own, `lastHotkeyCode` becomes `$C3` - and `$E04A` does **not** change,
because `$E31F cpx #$80` fails. "The key got in" and "the hot key fired" are therefore two different
observations, and the sweep separates them.
Two further keys were tried in an earlier boot and are recorded for the next person:
* `Alt_L` - `$E046` stayed `$FF`, and worse, **every key sent after it was swallowed**: holding Alt
opens the GTK menu bar, which takes a keyboard grab. The rest of that run was junk. Do not put
Alt in a candidate sweep; `testHotkeys.py` no longer does.
* `Control_R` - no effect, `$E046` = `$FF`, but this was measured after `Alt_L` had already wedged
that run, so it proves nothing.
That boot's log is `testLogs/hotkeyKeymapSweep.txt`; its `Control_L`, `Tab` and `Super_L` rows match
the table above exactly.
## 2. C= + C toggles the override on, and off again
Taken at the "GET OPPONENT ON PHONE AND PRESS A OR O" prompt, where the module is resident
(`$E012` reads `JMP $0DB7`, the game's `scanKeyboard`) and the raster IRQ is already calling `$E015`
every frame:
| when | `$E04A` |
|---|---|
| before | `$00` |
| after one C= + C | `$40` |
| after a second C= + C | `$00` |
`$40` is `carrierOverrideToggleMask` `$E04B`, i.e. bit 6, and the second press clears it again, which
is what `$E339 eor carrierOverrideFlags` is supposed to do. The auto-repeat filter at `$E312` does
not get in the way as long as the key is released between presses: with nothing held, `scanKeyboard`
returns `$FF`, and `lastHotkeyCode` `$E046` is reloaded with `$FF`, so the next `$C3` is a fresh code.
Note that the hot key works before the link is open (`isLinkActive` `$E03B` = `$00` in all three rows
above). The override flag is set; it simply has nothing to act on yet, because the carrier sampler
returns early at `$E5E7` while the link is down.
## 3. What the override does to the link state the game reads
The link was then opened for real - `A` at the answer/originate prompt, then space - and came up on
the first attempt: `aciaCommandShadow` `$E5BE` = `$05`, `$E03B` `isLinkActive` = `$01`, control
register `$DE03` = `$15` (8N1, internal clock, 300 baud), `connectionPhase` `$E040` = `$02`. The far
end of the serial line is a TCP sink that accepts the connection and never answers, so the link stays
in byte sync; 10697 bytes came out of the emulated 6551 during the run, beginning
`0d 41 54 51 30 56 31 58 31 41 0d` = CR `ATQ0V1X1A` CR, the answer-mode Hayes string, followed by the
`$00` stream of `beginByteSyncPhase`.
Two things about the game screen while all of this was going on, so the pictures are not
misleading: with a peer that never answers, the game puts up "PHONE TROUBLE."
(`shots/hotkeys03linkOpen.png`) and later "PICK UP PHONE THEN PRESS SPACE."
(`shots/hotkeys05borderHeld.png`), while the module itself stays in `connectionPhase` `$02` with
`isLinkActive` = `$01` throughout. The hot keys keep working in both of those states -
`inputLockoutTimer` `$0B7D` read `$00` at every measurement, so the raster IRQ was polling the
keyboard through `$E015` the whole time.
With that running, C= + C on and off again:
| state | `$E03B` isLinkActive | `$E03C` linkStatus | `$E03D` linkStatusSample | `$E5BC` aciaStatusSave |
|---|---|---|---|---|
| override off | `$01` | `$78` | `$78` | `$90` |
| override on (read immediately) | `$01` | `$40` | `$40` | `$90` |
| override on, six seconds later | `$01` | `$40` | `$40` | `$90` |
| override off again | `$01` | `$78` | `$78` | `$90` |
| override off, six seconds later | `$01` | `$78` | `$78` | `$90` |
Those two values are the driver's own fingerprints, from `$E5F8`-`$E602`:
* `$78` = a **real** carrier sample. `#$F0` -> `SEC` -> `ROR` = `$F8`, minus bit 7 because the line
has been quiet (no character has arrived recently, so `rearmCarrierTimer` `$E5FF` strips it).
* `$40` = an **override** sample. The override branch enters the same `SEC` / `ROR` with `$80`, so
it produces `$C0`, minus the same bit 7 = `$40`.
So the override is not merely "carrier present" - it is visibly a different route to it, and the
round trip back to `$78` shows the second keypress really cleared the flag. `$E03B` is not
carrier-derived and stays `$01` throughout, exactly as `spec_link.md` says.
## 4. The case a null-modem cable with no DCD wire depends on
**VICE cannot produce "no carrier" on its own.** On a SwiftLink the modem's DCD arrives on 6551
status **bit 6** (CMD swapped DCD and DSR at the chip), and that is the bit the carrier sampler
`serviceCarrierAndSuspendRequest` `$E5D6` tests with `BIT aciaStatusSave`. Every status byte VICE's emulated 6551 produced in this
project - `$30` with the device closed, `$10`, `$50`, `$88`, `$90`, `$98` while it is open - has bit 6
clear. VICE drives bit 5, the 6551's own DCD bit, and that only tracks whether the rs232 device is
open. So in VICE the driver always sees carrier, and the override can never be the difference
between a link and no link *by itself*.
It can be made the difference where the driver actually reads DCD: `aciaStatusSave` `$E5BC`, the
shadow the NMI keeps and the only DCD the carrier sampler ever sees. The test breaks at `$E5E9` -
the `LDA carrierOverrideFlags` one instruction before the DCD test - pokes `$E5BC` to `$40` (bit 6
set = DCD deasserted = no carrier), and single steps the sampler. Both passes below used exactly
that same forced input, and `$E5BC` was read back as `$40` after each one, so nothing refreshed it in
between.
**Override on** (`$E04A` = `$40`) - the DCD test is never executed:
```
.C:e5e9 AD 4A E0 LDA $E04A A:01
.C:e5ec 0A ASL A A:40 <- carrierOverrideFlags = $40 ...
.C:e5ed 30 09 BMI $E5F8 A:80 <- ... so bit 6 is now bit 7: branch taken,
.C:e5f8 38 SEC A:80 $E5F1 BIT aciaStatusSave is skipped entirely
.C:e5f9 6A ROR A A:80
.C:e5fa EE 45 E0 INC $E045 A:C0 <- sample = $C0: carrier, by override
.C:e5fd 10 05 BPL $E604 A:C0 <- quiet line, so bit 7 comes off
.C:e5ff 8D 45 E0 STA $E045 A:C0
.C:e602 29 7F AND #$7F A:C0
.C:e604 8D 3D E0 STA $E03D A:40 <- published: bit 6 set = carrier present
.C:e607 AD BB E5 LDA $E5BB A:40 <- on into startNextTxChar, and back out
.C:e60a D0 56 BNE $E662 A:01
.C:e662 60 RTS A:01
.C:e3ce AD 3D E0 LDA $E03D A:01
.C:e3d1 CD 3C E0 CMP $E03C A:40
.C:e3d4 F0 0D BEQ $E3E3 A:40 <- agrees with linkStatus, debounce reset
```
readback: `$E03D` = `$40`, `$E03C` = `$40`, `$E5BC` = `$40`.
**Override off** (`$E04A` = `$00`), same forced `$E5BC` = `$40`:
```
.C:e5ec 0A ASL A A:00
.C:e5ed 30 09 BMI $E5F8 A:00 <- not taken
.C:e5ef A9 F0 LDA #$F0 A:00
.C:e5f1 2C BC E5 BIT $E5BC A:F0 <- V := bit 6 of $40 = 1
.C:e5f4 50 02 BVC $E5F8 A:F0 <- not taken: DCD says no carrier
.C:e5f6 A9 00 LDA #$00 A:F0
.C:e5f8 38 SEC A:00
.C:e5f9 6A ROR A A:00
.C:e5fa EE 45 E0 INC $E045 A:80 <- sample = $80: no carrier
.C:e5fd 10 05 BPL $E604 A:80
.C:e5ff 8D 45 E0 STA $E045 A:80
.C:e602 29 7F AND #$7F A:80
.C:e604 8D 3D E0 STA $E03D A:00 <- published: bit 6 clear = no carrier
```
readback: `$E03D` = `$00`, `$E03C` = `$78` (unchanged - `pollCarrierState` needs 240 disagreeing
frames before it accepts a new value), `$E5BC` = `$40`.
**That is the whole point of the hot key, in two traces.** Identical DCD input; with the override
off the driver publishes "no carrier" and the game would tear the session down four seconds later
(`pollMenuSession` `$56B1` watches `$E03C` bit 6); with the override on it publishes "carrier" and
never looks at the line at all. A three-wire null-modem cable, where DCD is not wired and the
cartridge's pull-up already reads as carrier, does not need this - but a cable that does carry a real
DCD from a peer that is not asserting it now demonstrably does.
## 5. A baud hot key really reprograms the ACIA
Same session, link up, at true C64 speed:
| after | `$E055` baudIndex | `$E056` liveControlByte | `$DE03` ACIA control |
|---|---|---|---|
| (before) | `$00` | `$15` | `$15` |
| C= + 2 | `$06` | `$18` | `$18` |
| C= + 3 | `$00` | `$15` | `$15` |
`$18` is entry 6 of `baudEntryTable` `$E6FD` - 2400 baud on a SwiftLink's doubled clock - and it is a
value no earlier test in this project had ever written; `testReport.md` recorded only `$1E` (the
probe pattern) and `$15`. C= + 3 puts it back to 300. Both the index and the register move
together, so the backwards walk of `baudEntryTable` at `$E344` and `loadBaudParameters` `$E353` are
both doing what section 3 of the README claims.
## 6. The border indicator, which is the C= key's other job
`readKeyAndHandleModemHotkeys` also paints `VIC_BORDER` from `linkStatusBorderTable` `$E05F` while
the Commodore key is held, indexed by `connectionPhase*2 + carrier`. With the link up in
`connectionPhase` `$02` and `linkStatus` `$78` (carrier) the index is 5, whose table byte is `$04` =
red, with the low bit clear so it is painted only while C= is held.
A checkpoint on the module's own `STA VIC_BORDER` at `$E30C`, armed first and Tab pressed afterwards:
```
#1 (Stop on exec e30c)
.C:e30c 8D 20 D0 STA $D020 - A:02 X:80 Y:00 SP:de N.-.....
```
`A` = `$02` is red - `$04` from the table, halved by the `LSR` at `$E305` - and `X` = `$80` is
`scanKeyboard`'s "the Commodore key is held" flag, which is what took the `BMI` at `$E308`. So the
paint really happens; the code that does it runs from the raster IRQ, so it repeats every frame for
as long as the key is down.
Reading `$D020` is not how to see it: it was `$FC`, the game's own border colour, both at that
breakpoint (where the store had not executed yet) and again after the key was released, so the game
rewrites the border later in the same frame and a monitor peek at an arbitrary moment nearly always
catches the game's value. What the effect looks like on screen with the key held was not captured -
the screenshot in `shots/` was taken after the key came up.
## 7. Three things about the rig that the next person needs
1. **VICE 3.7.1 has no `WarpMode` resource but its monitor does have a `warp` command.**
`testReport.md` only tried `resourceset "WarpMode"/"Warp"/"WarpModeEnabled"`, all rejected, and
concluded every run stays in warp. `warp on`, `warp off` and `warp` (query, answers
"Warp mode is on./off.") all work. It matters more than it sounds, because **`-warp` on the
command line does not survive the autostart**: the first run of this test, launched exactly like
every run in `testReport.md`, sat for over eleven minutes at "loading under warp" and never
reached the menu, while the same script with an explicit `warp on` issued before the `$0800`
breakpoint reached the options menu in about 50 seconds, twice in a row. VICE's own log says
`AUTOSTART: aborting.` a second or two in - this disk's fast loader leaves the KERNAL routines
VICE is watching - and `-autostart-warp` turning warp off at that point is the obvious
explanation, though that part is inference, not measurement.
That is very probably the "90 seconds to over five and a half minutes" variation `testReport.md`
recorded. `testHotkeys.py` now warps the loader explicitly and issues `warp off` afterwards, so
every key in this report was pressed against a machine running at true C64 speed. (Between the
two traces in section 4 the emulated cycle counter advanced 73.8 M cycles = 72 s of NTSC C64
time, over a stretch of wall clock of the same order - not the hundreds of seconds warp would
have produced.)
2. **Do not stop the emulator while a key is held.** A key pressed with `xdotool keydown` and then
left down across a monitor stop does not survive the stop. This was the difference between two
otherwise identical runs: with Tab pressed *first* and the `$E30C` checkpoint armed during the
resulting monitor stop, the checkpoint never fired; with the checkpoint armed first and Tab
pressed after the machine was running again, it fired immediately (section 6). Anything that
needs a key to be down *while the CPU runs* must arm its breakpoints first and press afterwards.
Reads taken during a stop are still fine - they show what the held key already did.
3. **`Alt_L` wedges the run.** Holding Alt opens the GTK menu bar and takes a keyboard grab; every
`xdotool` key after that goes to the menu and the emulator sees nothing. `testHotkeys.py`
verifies key delivery before and after the sweep (`[keys] plain C: lastHotkeyCode = $C3`) so this
failure mode announces itself instead of silently producing zeros.
## 8. What this does not say
* **Still nothing on real hardware.** This is VICE 3.7.1 with `-acia1mode 1 -acia1base 0xDE00
-acia1irq 1`.
* **The "no carrier" DCD was injected, not emulated.** VICE never raises 6551 status bit 6, so the
value was written into `aciaStatusSave` `$E5BC` at a breakpoint. That is the exact byte the
carrier sampler reads and the exact bit the source documents, but a real SwiftLink with a real
peer dropping DCD has not been observed.
* **Only one machine.** The peer was a TCP sink that never answers, so the link stayed in
`connectionPhase` `$02`; the override was not tested with two machines actually playing.
* **Only C= + C, C= + 2, C= + 3 and C= on its own were pressed.** C= + H (DTR), C= + P (hang up),
C= + A / C= + O, C= + RETURN and the other four baud keys are still untested.
* **The disk-load suspend handshake, W65C51N boards and the `$DF00` fallback** remain untested, as
`testReport.md` already said.
## Files
| file | what it is |
|---|---|
| `testHotkeys.py` | the test: keymap sweep, override toggle, border checkpoint, forced-DCD traces, baud hot keys |
| `testLogs/hotkeyRun.txt` | the session this report cites, monitor traffic included |
| `testLogs/hotkeyKeymapSweep.txt` | the earlier boot whose sweep also tried `Alt_L` and `Control_R` |
| `shots/hotkeys01menu.png` | the title screen, five seconds after the loader finished |
| `shots/hotkeys02modemPrompt.png` | "GET OPPONENT ON PHONE AND..." - where the keymap sweep was done |
| `shots/hotkeys03linkOpen.png` | after A and space, with the link open and the module in phase 2: the game says "PHONE TROUBLE." |
| `shots/hotkeys04overrideOn.png` | the same screen with the carrier override on |
| `shots/hotkeys05borderHeld.png` | the screen the border checkpoint was taken on (the key is already released, so the border is the game's own colour) |