modemwars/swiftlink/testHotkeys.py
2026-08-23 02:09:40 -05:00

416 lines
18 KiB
Python

#!/usr/bin/env python3
# testHotkeys.py - exercise the module's Commodore-key hot keys, which no earlier test could reach.
#
# python3 testHotkeys.py <disk.d64> [--warp]
#
# The one thing that has to be worked out before any of this is possible is which *host* key VICE
# turns into the C64's Commodore key, because the game does not use the KERNAL: scanKeyboard $0DB7
# reads the CIA1 matrix itself, once per raster IRQ, and reports the Commodore key only when matrix
# row 7 column 5 is down. So the question is purely "which X keysym does VICE's active keymap put at
# 7/5", and the script answers it by experiment: hold each candidate together with C and watch
# carrierOverrideFlags ($E04A) bit 6, which C= + C toggles and nothing else in the module touches.
#
# Because the matrix is sampled once a frame, every key here is pressed with xdotool keydown, held
# for the better part of a second, and only then released. A tap ("xdotool key") is what earlier
# attempts used and it is not reliably visible to a once-a-frame scanner.
#
# What is checked, in order:
# 1. which host key is the Commodore key (all candidates recorded, including the ones that fail);
# 2. C= + C toggles $E04A bit 6 on, and off again;
# 3. with the link open, what the override does to $E03B/$E03C/$E03D;
# 3a. that the module really paints the border while C= is held, by breaking on its own store;
# 4. what the override does when the ACIA's DCD says "no carrier" - forced into aciaStatusSave
# ($E5BC) at a breakpoint, because VICE never raises that bit by itself (see the report);
# 5. a baud hot key (C= + 2, then C= + 3 to put it back) changing the ACIA control register $DE03.
#
# Timing: the loader is warped through with the monitor's own "warp on" (VICE 3.7.1 has no WarpMode
# resource, and -warp on the command line does not survive the autostart), and warp is switched off
# again before the first key, so every hot key here is pressed at true C64 speed. Pass --warp to
# stay in warp for the test phase as well.
import os
import subprocess
import sys
import time
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from viceHarness import ViceSession, aciaArgs, readByte, SCRATCH
from testModemSelect import SerialSink
SHOTS = os.path.join(os.path.dirname(os.path.abspath(__file__)), "shots")
# Everything worth watching while a hot key is pressed. $DE01/$DE03 are read with the monitor's
# side effects turned off, so looking at the status register does not clear an IRQ the driver has
# not seen yet.
STATE_ADDRS = [
("carrierOverrideFlags", 0xE04A),
("isLinkActive", 0xE03B),
("linkStatus", 0xE03C),
("linkStatusSample", 0xE03D),
("connectionPhase", 0xE040),
("lastHotkeyCode", 0xE046),
("aciaStatusSave", 0xE5BC),
("aciaCommandShadow", 0xE5BE),
("baudIndex", 0xE055),
("liveControlByte", 0xE056),
("aciaStatusReg", 0xDE01),
("aciaControlReg", 0xDE03),
("inputLockoutTimer", 0x0B7D),
("borderColour", 0xD020),
]
# Left Control and Tab are the two candidates the C64 keymaps actually use for the Commodore key
# (gtk3_pos.vkm puts it on Control_L, gtk3_sym.vkm on Tab); Super_L is here as a control - a key no
# C64 keymap claims - so that the sweep shows what "the letter arrived but no hot key fired" looks
# like. Alt_L was in this list and has been removed: holding Alt opens the GTK menu bar, which
# takes a keyboard grab, and every key sent after that went to the menu instead of the emulator.
CANDIDATES = ["Control_L", "Tab", "Super_L"]
def hx(value):
return "??" if value is None else f"${value:02X}"
def pause(session):
session.enterMonitor()
def resume(session):
session.sock.sendall(b"x\n")
session.recv(2)
def keyDown(session, keysym):
subprocess.run(["xdotool", "keydown", keysym], env=session.env, check=False)
def keyUp(session, keysym):
subprocess.run(["xdotool", "keyup", keysym], env=session.env, check=False)
def holdCombo(session, modifier, key, ms=900):
# The modifier goes down first and comes up last, exactly as a player would do it. ms is real
# time, so at true C64 speed 900 ms is about 54 frames of the game's own keyboard scan.
keyDown(session, modifier)
time.sleep(0.2)
keyDown(session, key)
time.sleep(ms / 1000.0)
keyUp(session, key)
time.sleep(0.1)
keyUp(session, modifier)
time.sleep(0.4)
def setWarpMode(session, on):
# VICE 3.7.1 has no WarpMode *resource* - testReport.md records all three spellings being
# rejected - but its monitor does have a "warp" command, which is how this run gets the loader
# over with quickly and still does the key work at true C64 speed.
pause(session)
session.mon("warp " + ("on" if on else "off"))
out = session.mon("warp")
resume(session)
return out
def snapshot(session, label, addrs=STATE_ADDRS):
pause(session)
state = {}
for name, addr in addrs:
state[name] = readByte(session, addr)
resume(session)
text = " ".join(f"{n}={hx(state[n])}" for n, _ in addrs)
print(f"[state] {label}: {text}", flush=True)
return state
def pickModemOpponent(session):
# Options menu: the highlighted row is $91D5 and row 0 is COMPETE WITH MODEM OPPONENT. KP_8 is
# joystick up, KP_0 is fire (VICE's numpad joystick uses KP_0, not KP_5).
for _ in range(5):
pause(session)
row = readByte(session, 0x91D5)
resume(session)
print(f"menu row = {row}", flush=True)
if row == 0:
break
session.hold("KP_8", 250)
time.sleep(0.8)
session.hold("KP_0", 300)
time.sleep(2)
def waitForModule(session, timeout=240):
# $E012 is the module's keyboard hook; initCommModule patches its operand to scanKeyboard $0DB7
# once the modem module has been loaded from track 18 s7 + track 34. Until that has happened
# there is no hot-key layer to talk to.
deadline = time.time() + timeout
while time.time() < deadline:
pause(session)
opcode = readByte(session, 0xE012)
lo = readByte(session, 0xE013)
hi = readByte(session, 0xE014)
entry = readByte(session, 0xE015)
resume(session)
if opcode == 0x4C and lo == 0xB7 and hi == 0x0D:
print(f"modem module resident: $E012 = JMP $0DB7 (scanKeyboard), $E015 opcode = "
f"{hx(entry)}", flush=True)
return True
time.sleep(3)
print("modem module never became resident", flush=True)
return False
def checkKeyDelivery(session):
# Prove that key events are still reaching the emulated matrix: tap C on its own and watch
# lastHotkeyCode ($E046), which the module writes on every new key code it sees. $C3 means the
# key got in; $FF means nothing arrived and every result after this point would be meaningless.
keyDown(session, "c")
time.sleep(0.6)
pause(session)
code = readByte(session, 0xE046)
resume(session)
keyUp(session, "c")
time.sleep(0.4)
print(f"[keys] plain C: lastHotkeyCode = {hx(code)} "
f"({'reaching the matrix' if code == 0xC3 else 'NOT reaching the matrix'})", flush=True)
return code == 0xC3
def findCommodoreKey(session):
# Hold candidate + C and see whether the module's C= + C hot key fired. Two things are read
# while the keys are still down: $E04A (the flag the hot key toggles) and $E046 lastHotkeyCode,
# which is the code the driver actually saw - $C3 means "C= + C reached the hot-key layer".
results = []
winner = None
for candidate in CANDIDATES:
pause(session)
before = readByte(session, 0xE04A)
resume(session)
keyDown(session, candidate)
time.sleep(0.2)
keyDown(session, "c")
time.sleep(0.9)
pause(session)
held = readByte(session, 0xE04A)
code = readByte(session, 0xE046)
border = readByte(session, 0xD020)
resume(session)
keyUp(session, "c")
time.sleep(0.1)
keyUp(session, candidate)
# A candidate the emulator does not use may still mean something to the GTK front end (Alt
# opens the menu bar), and a popped-up menu would swallow every key from here on, so take
# the input focus back before looking at the result.
session.focus()
time.sleep(0.5)
pause(session)
after = readByte(session, 0xE04A)
resume(session)
ok = (before is not None and after is not None and (before ^ after) == 0x40)
results.append({"candidate": candidate, "before": before, "held": held, "after": after,
"lastHotkeyCode": code, "border": border, "toggled": ok})
print(f"[candidate] {candidate:10s} $E04A {hx(before)} -> {hx(after)} "
f"(held {hx(held)}) lastHotkeyCode={hx(code)} border={hx(border)} "
f"{'TOGGLED' if ok else 'no effect'}", flush=True)
if ok and winner is None:
winner = candidate
return winner, results
def answerModemPrompts(session, answerKey="a"):
# "GET OPPONENT ON PHONE AND PRESS A OR O", then "PRESS SPACE, WAIT, HANGUP PHONE". A key that
# arrives before the prompt is simply lost, so offer both until aciaCommandShadow ($E5BE) stops
# reading back zero, which only happens once configureUserPortLines has programmed the 6551.
for attempt in range(20):
session.hold(answerKey, 300)
session.hold("space", 300)
time.sleep(1.5)
pause(session)
shadow = readByte(session, 0xE5BE)
active = readByte(session, 0xE03B)
resume(session)
if shadow:
print(f"link opened after {attempt + 1} attempt(s): aciaCommandShadow = {hx(shadow)}, "
f"isLinkActive = {hx(active)}", flush=True)
return True
print("link never opened", flush=True)
return False
def toggleOverride(session, commodoreKey, label):
holdCombo(session, commodoreKey, "c")
return snapshot(session, label)
def borderCheck(session):
# The other half of the Commodore key's job: while it is held the module paints the border from
# linkStatusBorderTable. Reading $D020 afterwards is not proof, because the game paints the
# border too; a breakpoint on the module's own store at $E30C is.
# Arm the checkpoint *before* the key goes down: entering the monitor appears to clear VICE's
# emulated key matrix, so a breakpoint set while the key is held is armed on a machine that has
# already forgotten about it. This ordering never stops the machine with a key down.
pause(session)
session.mon("break e30c")
resume(session)
keyDown(session, "Tab")
hit = session.recv(10)
print(f"[border] break on the module's STA VIC_BORDER at $E30C while C= is held:\n{hit[-300:]}",
flush=True)
border = readByte(session, 0xD020)
phase = readByte(session, 0xE040)
status = readByte(session, 0xE03C)
session.mon("del")
resume(session)
keyUp(session, "Tab")
time.sleep(0.3)
pause(session)
after = readByte(session, 0xD020)
resume(session)
print(f"[border] $D020 at the module's store = {hx(border)}, after the frame finished = "
f"{hx(after)}, connectionPhase = {hx(phase)}, linkStatus = {hx(status)}", flush=True)
return {"hit": hit, "borderAtStore": border, "borderAfter": after, "connectionPhase": phase,
"linkStatus": status}
def forcedDcdTrace(session, label):
# VICE's emulated 6551 never sets status bit 6, which is where a SwiftLink presents DCD (CMD
# swapped DCD and DSR at the chip), so the "no carrier" case cannot be produced by the emulator.
# It can be produced where the driver actually reads it: aciaStatusSave $E5BC, the shadow the NMI
# keeps and the only DCD the carrier sampler ever sees. Break at $E5E9 - the instruction that
# loads carrierOverrideFlags, one instruction before the DCD test - poke the shadow, and single
# step through the sampler to watch which way it goes.
pause(session)
session.mon("break e5e9")
resume(session)
hit = session.recv(30)
print(f"[trace {label}] breakpoint hit:\n{hit[-400:]}", flush=True)
session.mon("> e5bc 40")
check = readByte(session, 0xE5BC)
print(f"[trace {label}] aciaStatusSave forced to {hx(check)} (bit 6 set = DCD says NO "
f"carrier)", flush=True)
# Sixteen single steps: the longest way through the sampler (override off, DCD says no carrier,
# quiet line) is fourteen instructions from $E5E9 to the STA at $E604 that publishes the sample,
# so sixteen always lands past the store and a few bytes into startNextTxChar.
steps = []
for _ in range(16):
steps.append(session.mon("z"))
sample = readByte(session, 0xE03D)
status = readByte(session, 0xE03C)
flags = readByte(session, 0xE04A)
shadow = readByte(session, 0xE5BC)
print(f"[trace {label}] after the sampler ran: carrierOverrideFlags={hx(flags)} "
f"aciaStatusSave={hx(shadow)} linkStatusSample={hx(sample)} linkStatus={hx(status)}",
flush=True)
session.mon("del")
resume(session)
return {"trace": "".join(steps), "linkStatusSample": sample, "linkStatus": status,
"carrierOverrideFlags": flags, "aciaStatusSave": shadow}
def main():
disk = os.path.abspath(sys.argv[1])
warp = "--warp" in sys.argv[2:]
os.makedirs(SHOTS, exist_ok=True)
sink = SerialSink()
print(f"serial sink on 127.0.0.1:{sink.port}; keep warp for the test phase = {warp}",
flush=True)
args = aciaArgs(rsDevAddress=f"127.0.0.1:{sink.port}", baud=2400)
session = ViceSession(disk, f"{SCRATCH}/hotkeys.vice.log", args, label="hk", warp=True)
findings = {}
try:
session.connect()
session.mon('sidefx off')
session.mon('resourceget "KeymapIndex"')
session.mon('resourceget "KeymapSymFile"')
session.mon('resourceget "KeymapPosFile"')
session.mon('resourceget "KeymapUserSymFile"')
# -autostart-warp turns warp off again when the autostart is done, which leaves the game's
# own fast loader crawling at 1x, so ask for warp explicitly before waiting for $0800.
setWarpMode(session, True)
session.bootPastLoader(waitSecs=900)
# The loader is done; from here on the game runs at true C64 speed, so a key held for 900 ms
# really is held for about 54 of the game's once-a-frame keyboard scans.
if not warp:
setWarpMode(session, False)
session.findWindow()
session.focus()
time.sleep(5)
session.shot(f"{SHOTS}/hotkeys01menu.png")
pickModemOpponent(session)
if not waitForModule(session):
raise SystemExit("the modem module never loaded - nothing to test")
session.shot(f"{SHOTS}/hotkeys02modemPrompt.png")
# 1. which host key is the Commodore key
findings["keyDeliveryBefore"] = checkKeyDelivery(session)
commodoreKey, findings["candidates"] = findCommodoreKey(session)
findings["keyDeliveryAfter"] = checkKeyDelivery(session)
print(f"=== Commodore key = {commodoreKey} ===", flush=True)
if commodoreKey is None:
raise SystemExit("no candidate host key reached the module as the Commodore key")
findings["commodoreKey"] = commodoreKey
# The sweep leaves the override wherever its own presses left it; put it back to 0 so the
# on/off demonstration below starts from a known state.
pause(session)
flags = readByte(session, 0xE04A)
resume(session)
if flags:
holdCombo(session, commodoreKey, "c")
# 2. on and off again, before the link is even open (the hot-key layer runs regardless)
findings["preLinkOff"] = snapshot(session, "before any C= + C")
findings["preLinkOn"] = toggleOverride(session, commodoreKey, "after C= + C #1")
findings["preLinkOff2"] = toggleOverride(session, commodoreKey, "after C= + C #2")
# The border indicator is the other half of the C= key's job: hold it alone and the module
# paints the border from linkStatusBorderTable. Only meaningful once the link is up.
# 3. open the link and look at what the override does to the published link state
answerModemPrompts(session)
time.sleep(3)
session.shot(f"{SHOTS}/hotkeys03linkOpen.png")
findings["linkOverrideOff"] = snapshot(session, "link open, override OFF")
# 3a. the border indicator, while the link is up and the game is still on the link screen
findings["borderHeld"] = borderCheck(session)
session.shot(f"{SHOTS}/hotkeys05borderHeld.png")
findings["overrideOnImmediate"] = toggleOverride(session, commodoreKey,
"link open, override ON (immediately)")
time.sleep(6)
findings["overrideOnDebounced"] = snapshot(session, "link open, override ON (after 6 s)")
session.shot(f"{SHOTS}/hotkeys04overrideOn.png")
# 4. the case a real null-modem cable with no DCD wire depends on
findings["forcedDcdOverrideOn"] = forcedDcdTrace(session, "override ON")
findings["overrideOffAgain"] = toggleOverride(session, commodoreKey,
"link open, override OFF again")
time.sleep(6)
findings["overrideOffDebounced"] = snapshot(session, "link open, override OFF (after 6 s)")
findings["forcedDcdOverrideOff"] = forcedDcdTrace(session, "override OFF")
# 5. a baud hot key
pause(session)
session.mon("m e6fd e70f")
resume(session)
findings["baudBefore"] = snapshot(session, "before C= + 2")
holdCombo(session, commodoreKey, "2")
findings["baud2400"] = snapshot(session, "after C= + 2 (expect $DE03 = $18, 2400 baud)")
holdCombo(session, commodoreKey, "3")
findings["baud300"] = snapshot(session, "after C= + 3 (expect $DE03 = $15, 300 baud)")
findings["final"] = snapshot(session, "final")
finally:
session.close()
time.sleep(1)
sink.stop()
print(f"serial sink connected={sink.connected} bytes={len(sink.data)}", flush=True)
print("first serial bytes:", sink.data[:64].hex(" "), flush=True)
import json
path = f"{SCRATCH}/hotkeyFindings.json"
with open(path, "w") as handle:
json.dump(findings, handle, indent=1, default=str)
print(f"findings written to {path}", flush=True)
if __name__ == "__main__":
main()