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

270 lines
14 KiB
Python

#!/usr/bin/env python3
# testStack.py - catch the 38400-baud wedge while it is happening, rather than after.
#
# python3 testStack.py <disk.d64> <rate> [tag] [watchSeconds]
#
# testWedge.py established what a wedged machine looks like afterwards: the game running with the I
# flag set so its raster IRQ never runs again, and the whole of page 1 covered in commNmiHandler
# frames. That is wreckage, not a mechanism. This script watches for the mechanism.
#
# The 6502 does not mask NMI, so a fresh /NMI edge re-enters commNmiHandler at the next instruction
# boundary, and every re-entry costs 6 bytes of stack: the CPU's own PCH/PCL/P plus the handler's A,
# X and saved $01. So the stack pointer AT THE HANDLER'S FIRST INSTRUCTION is a direct read-out of
# how many NMIs are nested at that moment - and VICE prints SP and the free-running cycle counter on
# every tracepoint hit.
#
# trace exec e685 if sp < $a8 - prints nothing while nesting is shallow (the main line runs with
# SP around $BD-$E5, so an entry below $A8 is at least three deep)
# and turns into a staircase the moment the handler stops keeping
# up. Cheap: no output at all until it matters.
# break exec e685 if sp < $20 - stops the emulator once the stack has nearly wrapped, so the
# stack page, the registers and the ACIA can be read with the
# runaway still in progress instead of after it has destroyed
# itself.
#
# Both machines carry both checkpoints, and the raw monitor output of each is saved, so the staircase
# is timestamped in the emulator's own cycles.
import os
import re
import sys
import time
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from viceHarness import ViceSession, aciaArgs, ACIA_BASE, SCRATCH
from testTwoMachines import NullModemRelay, pickModemOpponent, answerModemPrompts
from testRealtime import (BAUD_INDEX, LOGS, NTSC_HZ, SHOTS, command, decodeControl, flush,
pauseAll, readRange, resumeAll, snapshot, showState, stopwatch,
waitForPhase)
from testHighRates import moduleResident
from testWedge import TRACE_LINE, registers
DEEP_TRACE_SP = 0xB4 # one NMI frame below the deepest main line ever sampled
BREAK_SP = 0x20 # nearly a whole page of stack consumed
# A healthy machine never pushes below about $01A0: the deepest stack byte either emulator touched in
# a 200-second 38400-baud run that did not wedge was $01AE. So a STORE anywhere in $0100-$0180 means
# the stack has descended thirteen NMI frames further than anything healthy does, and it is the
# cheapest possible trigger - it costs nothing at all until the runaway starts.
DEEP_TRACE_STORE = (0x0100, 0x0180)
BREAK_STORE = (0x0100, 0x0140)
def collect(sessions, seconds, maxBytes, runTag):
# Read both monitor sockets raw, at the same time, and stop early if either machine's breakpoint
# fires. A resumed emulator prints nothing, so any prompt in the stream is the break.
buffers = {tag: [] for _, tag in sessions}
totals = {tag: 0 for _, tag in sessions}
broke = None
for session, _ in sessions:
session.sock.settimeout(0.2)
deadline = time.time() + seconds
while time.time() < deadline and broke is None:
for session, tag in sessions:
if totals[tag] >= maxBytes:
continue
try:
chunk = session.sock.recv(1 << 16)
except OSError:
continue
if not chunk:
continue
text = chunk.decode("latin-1")
buffers[tag].append(text)
totals[tag] += len(chunk)
if "Stop on" in text:
broke = tag
print(f"\n*** [{tag}] BREAKPOINT: the handler was entered with SP below "
f"${BREAK_SP:02X} ***", flush=True)
out = {}
for _, tag in sessions:
body = "".join(buffers[tag])
path = f"{LOGS}/stack.{runTag}.{tag}.deep.txt"
open(path, "w").write(body)
out[tag] = body
print(f"[{tag}] {totals[tag]} bytes of deep-nesting trace -> {path}", flush=True)
return out, broke
def staircase(body, tag):
# Every line is one entry into commNmiHandler with the stack already at least three frames deep.
# Consecutive lines whose SP falls by exactly 6 are consecutive nesting levels; the cycle gap
# between them is how long one pass of the handler managed before the next character arrived.
events = [(int(pc, 16), int(sp, 16), int(cycle)) for pc, sp, cycle in TRACE_LINE.findall(body)]
entries = [event for event in events if event[0] == 0xE685]
if not entries:
print(f" [{tag}] no entry to commNmiHandler was ever seen with SP < ${DEEP_TRACE_SP:02X}",
flush=True)
return {"tag": tag, "deepEntries": 0}
sps = [sp for _, sp, _ in entries]
gaps = [entries[i][2] - entries[i - 1][2] for i in range(1, len(entries))]
descending = sum(1 for i in range(1, len(entries))
if (entries[i - 1][1] - entries[i][1]) % 256 == 6)
print(f"\n ---- deep-nesting staircase [{tag}] ----", flush=True)
print(f" entries below SP ${DEEP_TRACE_SP:02X}: {len(entries)}", flush=True)
print(f" SP first ${sps[0]:02X}, last ${sps[-1]:02X}, lowest ${min(sps):02X}", flush=True)
print(f" steps where SP fell by exactly 6 (one more NMI frame): {descending} of "
f"{len(entries) - 1}", flush=True)
if gaps:
print(f" cycles between consecutive entries: min {min(gaps)}, median "
f"{sorted(gaps)[len(gaps) // 2]}, max {max(gaps)}", flush=True)
span = entries[-1][2] - entries[0][2]
print(f" the whole staircase took {span} cycles = {span / NTSC_HZ * 1000:.2f} ms", flush=True)
print(f" first 24: {[(f'${sp:02X}', cycle) for _, sp, cycle in entries[:24]]}", flush=True)
print(f" last 8: {[(f'${sp:02X}', cycle) for _, sp, cycle in entries[-8:]]}", flush=True)
return {"tag": tag, "deepEntries": len(entries), "spFirst": sps[0], "spLast": sps[-1],
"spLowest": min(sps), "stepsOfSix": descending, "spanCycles": span,
"gapMedian": sorted(gaps)[len(gaps) // 2] if gaps else None,
"gapMin": min(gaps) if gaps else None}
def dumpAtBreak(session, tag):
print(f"\n======== the machine, stopped inside the runaway [{tag}] ========", flush=True)
flush(session, 0.3, 3)
row = registers(session, tag)
print(f" [{tag}] registers: {row}", flush=True)
command(session, "d e685 e6f1", timeout=20)
command(session, "m 0100 017f", timeout=20)
command(session, "m 0180 01ff", timeout=20)
command(session, "m 0000 0001")
command(session, f"m {ACIA_BASE:04x} {ACIA_BASE + 3:04x}")
command(session, "m e5bb e5bf")
command(session, "m e42d e42f")
command(session, "m e414 e416")
command(session, "m e0a4 e0a6")
command(session, "m 0038 0038") # irqInProgress, the game's raster-IRQ guard
command(session, "m fffa fffb")
command(session, "stopwatch")
return row
def main():
disk = os.path.abspath(sys.argv[1])
rate = int(sys.argv[2])
tag = sys.argv[3] if len(sys.argv) > 3 else "s"
watchSeconds = float(sys.argv[4]) if len(sys.argv) > 4 else 200.0
# traceOnly: arm the tracepoints only, never a breakpoint, and start reading the moment the link
# is open. st384b caught a machine with SP already at $12, but it caught it with a BREAKPOINT,
# which stops the emulator - and once stopped it stays stopped, so the descent itself was never
# printed. Tracepoints print and let the machine run, so this mode records the staircase as it
# happens and never interferes.
traceOnly = len(sys.argv) > 5 and sys.argv[5] == "traceonly"
os.makedirs(SHOTS, exist_ok=True)
os.makedirs(LOGS, exist_ok=True)
relay = NullModemRelay()
print(f"relay on 127.0.0.1:{relay.port}; both machines want {rate} baud", flush=True)
args = aciaArgs(rsDevAddress=f"127.0.0.1:{relay.port}", baud=38400)
a = ViceSession(disk, f"{SCRATCH}/stack.{tag}.a.vice.log", args, label="A", warp=False)
b = ViceSession(disk, f"{SCRATCH}/stack.{tag}.b.vice.log", args, label="B", warp=False)
sessions = [(a, "A"), (b, "B")]
try:
a.connect()
b.connect()
for session, _ in sessions:
command(session, "warp on")
a.bootPastLoader(waitSecs=900)
b.bootPastLoader(waitSecs=900)
pauseAll(sessions)
for session, sessionTag in sessions:
command(session, "warp off")
print(f"[{sessionTag}] {command(session, 'warp').strip()}", flush=True)
resumeAll(sessions)
a.findWindow()
b.findWindow()
a.focus()
b.focus()
time.sleep(25)
pickModemOpponent(a, "A", "a")
pickModemOpponent(b, "B", "o")
for session, sessionTag in sessions:
if not moduleResident(session, sessionTag):
raise SystemExit(f"{sessionTag}: the opponent module never loaded")
pauseAll([(session, sessionTag)])
command(session, f"> e055 {BAUD_INDEX[rate]:02x}")
# Arm both checkpoints before the link is ever opened, so nothing that happens on the
# way up can be missed.
command(session, "del")
command(session, f"trace exec e685 if sp < ${DEEP_TRACE_SP:02x}")
if not traceOnly:
command(session, f"trace store {DEEP_TRACE_STORE[0]:04x} "
f"{DEEP_TRACE_STORE[1]:04x}")
command(session, f"break exec e685 if sp < ${BREAK_SP:02x}")
command(session, f"break store {BREAK_STORE[0]:04x} {BREAK_STORE[1]:04x}")
resumeAll([(session, sessionTag)])
answerModemPrompts(a, "A", "a")
answerModemPrompts(b, "B", "o")
if not traceOnly:
ok, phases = waitForPhase(sessions, 3, 180)
print(f"both in the packet phase: {ok} ({phases})", flush=True)
for state in [snapshot(session, sessionTag) for session, sessionTag in sessions]:
print(f" [{state['tag']}] control {decodeControl(state['aciaControl'])}",
flush=True)
showState(state)
# waitForPhase and snapshot both stop and restart the emulators, so re-arm and hand the
# sockets over to the raw collector with nothing else talking on them.
for session, sessionTag in sessions:
pauseAll([(session, sessionTag)])
command(session, "del")
command(session, f"trace exec e685 if sp < ${DEEP_TRACE_SP:02x}")
command(session, f"trace store {DEEP_TRACE_STORE[0]:04x} "
f"{DEEP_TRACE_STORE[1]:04x}")
command(session, f"break exec e685 if sp < ${BREAK_SP:02x}")
command(session, f"break store {BREAK_STORE[0]:04x} {BREAK_STORE[1]:04x}")
resumeAll([(session, sessionTag)])
# Bracket the watch with both machines' own cycle counters, so the run says for itself
# whether it was at true C64 speed.
pauseAll(sessions)
startCycles = {sessionTag: stopwatch(session) for session, sessionTag in sessions}
resumeAll(sessions)
t0 = time.time()
bodies, broke = collect(sessions, watchSeconds, 8 << 20, tag)
wall = time.time() - t0
if broke is None:
pauseAll(sessions)
endCycles = {sessionTag: stopwatch(session) for session, sessionTag in sessions}
resumeAll(sessions)
for sessionTag in startCycles:
span = endCycles[sessionTag] - startCycles[sessionTag]
print(f"[{sessionTag}] watch {wall:.2f} s wall, {span} emulated cycles = "
f"{span / NTSC_HZ:.2f} emulated s = "
f"{100.0 * span / NTSC_HZ / wall:.1f}% of real time", flush=True)
summary = {}
for _, sessionTag in sessions:
summary[sessionTag] = staircase(bodies[sessionTag], sessionTag)
if broke:
session = dict((sessionTag, s) for s, sessionTag in sessions)[broke]
dumpAtBreak(session, broke)
print(f"\n letting [{broke}] run on from the break", flush=True)
command(session, "del")
resumeAll([(session, broke)])
time.sleep(10)
pauseAll([(session, broke)])
print(f" [{broke}] ten seconds later: {registers(session, broke)}", flush=True)
command(session, "m 0038 0038")
command(session, "m e42d e42f")
command(session, "m e0a5 e0a5")
resumeAll([(session, broke)])
for session, sessionTag in sessions:
pauseAll([(session, sessionTag)])
command(session, "del")
row = registers(session, sessionTag)
print(f" [{sessionTag}] final registers: {row}", flush=True)
command(session, "m 0038 0038")
command(session, "m 0100 017f", timeout=20)
command(session, "m 0180 01ff", timeout=20)
resumeAll([(session, sessionTag)])
a.shot(f"{SHOTS}/st{tag}end.png")
b.shot(f"{SHOTS}/st{tag}endB.png")
print("\n================ SUMMARY ================", flush=True)
print(f" rate {rate}, tag {tag}, broke on {broke}", flush=True)
for sessionTag, row in summary.items():
print(f" {sessionTag}: {row}", flush=True)
finally:
a.close()
b.close()
time.sleep(1)
relay.stop()
for index in (0, 1):
print(f"[relay] conn{index} total {len(relay.log[index])} bytes", flush=True)
if __name__ == "__main__":
main()