416 lines
18 KiB
Python
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()
|