#!/usr/bin/env python3 # testHotkeys.py - exercise the module's Commodore-key hot keys, which no earlier test could reach. # # python3 testHotkeys.py [--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()