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

18 KiB

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.

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)