Skip to content

Commit fa685cb

Browse files
JC-000claude
andcommitted
uci: check_uci_read_pacing + mutant runner; label the kept stage fence
tools/check_uci_read_pacing.py runs the shipped adapter (net_init, connect, data / idle / EOF / reset polls, send, close) on test_uci_abort_recovery's interpreter and model, and logs every $DF1C-$DF1F access with the uci_fence executions after it: every write must be fenced; on the default image no read is, except the fence net_poll keeps before its header read (now labelled uci_poll_stage_fence; a label, no bytes); on a UCI_READ_FENCE=1 image every read is. --expect flips the expectation to show it red on the other image. tools/mutate_uci_read_pacing.sh: 5 source mutants (two write fences dropped, three read fences re-added); all killed, control 6/6. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 16557a9 commit fa685cb

3 files changed

Lines changed: 199 additions & 0 deletions

File tree

‎src/net/uci/net.s‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,7 @@ net_poll:
273273
; SMC dst. Loop style matches uci_read_resp_bytes — tight-poll
274274
; DATA_AV and read UCI_RESP_DATA; the firmware FIFO auto-advances
275275
; on read (Phase 2 finding), so NO per-byte DATA_ACC.
276+
uci_poll_stage_fence: ; kept: the one fence after a read that is
276277
uci_fence ; give firmware time to stage response
277278
ldy #$00
278279
@hdr_loop:

‎tools/check_uci_read_pacing.py‎

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
#!/usr/bin/env python3
2+
"""BACKEND=uci read pacing: a uci_fence after every UCI register WRITE,
3+
none after a READ (src/net/uci/uci_regs.inc, uci_read_fence).
4+
5+
Runs the SHIPPED machine code (build/c64-https.prg at build/labels.txt's
6+
addresses) on test_uci_abort_recovery.py's interpreter and Command
7+
Interface model, through net_init, a connect, data / idle / EOF / reset
8+
polls, a send and a close, and logs every access to $DF1C-$DF1F with the
9+
uci_fence executions that follow it before the next access. A fence is
10+
recognised by its bytes as assembled (that module's FENCE), whether it is
11+
inline or inside uci_settle.
12+
13+
every image each WRITE to $DF1C/$DF1D is followed by a fence before
14+
the next UCI access.
15+
default no READ is followed by a fence, except the one kept before
16+
net_poll's header read (label uci_poll_stage_fence).
17+
UCI_READ_FENCE=1 (flags.stamp)
18+
every READ is followed by a fence (the previous products).
19+
20+
What it does not see: a delay that is not a uci_fence (a NOP sled, a
21+
loop), the trust store's uci_dos.s (not on these flows; still fenced),
22+
and anything about the FPGA's real timing -- the hardware A/B in the PR
23+
is that evidence.
24+
25+
make BACKEND=uci ... && python3 tools/check_uci_read_pacing.py [--expect default|fenced]
26+
27+
--expect overrides what flags.stamp says, to show the check red on the
28+
other image. Standalone, like tools/check_m3_client.py. Exit 0 pass,
29+
1 fail, 2 cannot run.
30+
"""
31+
from __future__ import annotations
32+
33+
import sys
34+
from pathlib import Path
35+
36+
sys.path.insert(0, str(Path(__file__).resolve().parent))
37+
import test_uci_abort_recovery as A # noqa: E402
38+
import test_uci_data_acc as base # noqa: E402
39+
40+
UCI_REGS = range(0xDF1C, 0xDF20)
41+
42+
43+
class Log:
44+
def __init__(self):
45+
self.events = [] # [kind, addr, [fence pcs after it]]
46+
47+
def access(self, kind, addr):
48+
if addr in UCI_REGS:
49+
self.events.append([kind, addr, []])
50+
51+
def fence(self, pc):
52+
if self.events:
53+
self.events[-1][2].append(pc)
54+
55+
56+
def _traced(model, log):
57+
read, write = model.read, model.write
58+
59+
def r(addr):
60+
log.access("r", addr)
61+
return read(addr)
62+
63+
def w(addr, value):
64+
log.access("w", addr)
65+
return write(addr, value)
66+
model.read, model.write = r, w
67+
return model
68+
69+
70+
class PacingCPU(A.FastCPU):
71+
log = None
72+
73+
def step(self):
74+
pc = self.pc
75+
ram = self.mem.ram
76+
if ram[pc] == 0x48 and ram[pc:pc + len(A.FENCE)] == A.FENCE:
77+
self.log.fence(pc)
78+
super().step()
79+
80+
81+
def _machine(replies, log):
82+
model = _traced(A.AbortModel(replies), log)
83+
cpu, mem, labels = A._require(model)
84+
pcpu = PacingCPU(mem)
85+
pcpu.log = log
86+
return pcpu, mem, labels, model
87+
88+
89+
def flows():
90+
"""(name, Log) for each traced session."""
91+
out = []
92+
log = Log()
93+
cpu, mem, labels, uci = _machine([A.CONNECT_OK], log)
94+
cpu.call(labels["net_init"])
95+
A._assert_connected(cpu, mem, uci, labels, "pacing: connect")
96+
out.append(("init+connect", log))
97+
for name, reply in (("poll data", (b"\x40\x00" + bytes(range(64)), A.OK)),
98+
("poll idle", (b"\xff\xff", b"02,NO DATA: 11")),
99+
("poll EOF", (b"\x00\x00", b"01,CONNECTION CLOSED BY HOST")),
100+
("poll reset", (b"\xff\xff", b"02,NO DATA: 104"))):
101+
log = Log()
102+
cpu, mem, labels, uci = _machine([reply], log)
103+
mem.write(labels["uci_socket_id"], 1)
104+
mem.write(labels["net_tcp_state"], A.NET_TCP_CONNECTED)
105+
cpu.call(labels["net_poll"], budget=8_000_000)
106+
out.append((name, log))
107+
log = Log()
108+
cpu, mem, labels, uci = _machine([(b"\x3a\x00", A.OK), (b"", A.OK)], log)
109+
mem.write(labels["uci_socket_id"], 1)
110+
mem.write(labels["net_tcp_state"], A.NET_TCP_CONNECTED)
111+
mem.write(labels["net_send_len"], 58)
112+
mem.write(labels["net_send_len"] + 1, 0)
113+
cpu.a, cpu.x = 0x00, 0x04
114+
cpu.call(labels["net_tcp_send"], budget=4_000_000)
115+
cpu.call(labels["net_tcp_close"], budget=4_000_000)
116+
out.append(("send+close", log))
117+
return out, labels
118+
119+
120+
def _fenced_image():
121+
stamp = base.LABELS.parent / "flags.stamp"
122+
for line in stamp.read_text().splitlines():
123+
if line.startswith("CA65FLAGS="):
124+
return "-D UCI_READ_FENCE" in line
125+
raise A.Unavailable("%s has no CA65FLAGS line" % stamp)
126+
127+
128+
def main(argv) -> int:
129+
expect = None
130+
if len(argv) == 3 and argv[1] == "--expect" and argv[2] in ("default", "fenced"):
131+
expect = argv[2] == "fenced"
132+
elif len(argv) != 1:
133+
print(__doc__)
134+
return 2
135+
try:
136+
fenced = _fenced_image() if expect is None else expect
137+
traces, labels = flows()
138+
except (A.Unavailable, FileNotFoundError) as exc:
139+
print("COULD NOT RUN: %s" % exc)
140+
return 2
141+
keep = labels.get("uci_poll_stage_fence")
142+
if keep is None:
143+
print("COULD NOT RUN: uci_poll_stage_fence is not in labels.txt")
144+
return 2
145+
fails = 0
146+
for name, log in traces:
147+
ev = log.events
148+
writes = [e for e in ev[:-1] if e[0] == "w"]
149+
reads = [e for e in ev[:-1] if e[0] == "r"]
150+
bare = [e for e in writes if not e[2]]
151+
if fenced:
152+
bad = [e for e in reads if not e[2]]
153+
what = "unfenced read(s) in an UCI_READ_FENCE=1 image"
154+
else:
155+
bad = [e for e in reads if any(pc != keep for pc in e[2])]
156+
what = "fenced read(s) in the default image"
157+
ok = len(writes) > 3 and not bare and not bad
158+
fails += not ok
159+
print("%s %-13s %3d writes, %4d reads; %s%s" % (
160+
"PASS" if ok else "FAIL", name, len(writes), len(reads),
161+
"%d write(s) with no fence after" % len(bare) if bare else "writes fenced",
162+
"; %d %s, first $%04X" % (len(bad), what, bad[0][1]) if bad else ""))
163+
print("\n%d/%d flows pass (%s image)" % (len(traces) - fails, len(traces),
164+
"fenced" if fenced else "default"))
165+
return 1 if fails else 0
166+
167+
168+
if __name__ == "__main__":
169+
sys.exit(main(sys.argv))

‎tools/mutate_uci_read_pacing.sh‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
#!/usr/bin/env bash
2+
# tools/mutate_uci_read_pacing.sh -- break the BACKEND=uci read-pacing rule
3+
# in the source, one mutant at a time; rebuild (make BACKEND=uci, in build/:
4+
# it OVERWRITES the current build) and require tools/check_uci_read_pacing.py
5+
# to go red (exit 1) on each. The source is restored after every mutant.
6+
# Exit 0 only if every mutant is killed and the control passes.
7+
cd "$(dirname "$0")/.." || exit 1
8+
fails=0
9+
mut() { # name file old new
10+
python3 - "$2" "$3" "$4" <<'PY' || { echo "BROKEN $1"; fails=1; return; }
11+
import sys
12+
f, old, new = sys.argv[1], sys.argv[2].encode().decode("unicode_escape"), sys.argv[3].encode().decode("unicode_escape")
13+
s = open(f).read(); assert s.count(old) == 1, (f, old, s.count(old)); open(f + ".orig", "w").write(s); open(f, "w").write(s.replace(old, new))
14+
PY
15+
rm -rf build; make BACKEND=uci >build-mutant.log 2>&1 || { echo "BROKEN $1 (build)"; fails=1; mv "$2.orig" "$2"; return; }
16+
rm -f build-mutant.log
17+
out=$(python3 tools/check_uci_read_pacing.py 2>&1); rc=$?
18+
mv "$2.orig" "$2"
19+
if [ $rc -eq 1 ]; then echo "KILLED $1: $(echo "$out" | grep -m1 FAIL | cut -c1-120)"; else echo "SURVIVED $1 (rc=$rc)"; fails=1; fi
20+
}
21+
mut put-unfenced src/net/uci/uci_cmd.s "uci_put_byte:\n sta UCI_CMD_DATA\n uci_fence\n" "uci_put_byte:\n sta UCI_CMD_DATA\n"
22+
mut begin-unfenced src/net/uci/uci_cmd.s "uci_begin_cmd:\n sta UCI_CMD_DATA\n uci_fence\n" "uci_begin_cmd:\n sta UCI_CMD_DATA\n"
23+
mut copy-read-fenced src/net/uci/net.s " lda UCI_RESP_DATA\n uci_read_fence ; settle before storing data byte" " lda UCI_RESP_DATA\n uci_fence"
24+
mut status-wait-fenced src/net/uci/uci_cmd.s "@wr_loop:\n lda UCI_STATUS\n uci_read_fence" "@wr_loop:\n lda UCI_STATUS\n uci_fence"
25+
mut statline-fenced src/net/uci/uci_cmd.s " lda UCI_STATUS_DATA\n.ifdef UCI_READ_FENCE\n jsr uci_settle\n.endif" " lda UCI_STATUS_DATA\n jsr uci_settle"
26+
rm -rf build; make BACKEND=uci >/dev/null 2>&1
27+
python3 tools/check_uci_read_pacing.py >/dev/null || fails=1
28+
echo "control $(shasum -a 256 build/c64-https.prg | cut -c1-8): $(python3 tools/check_uci_read_pacing.py | tail -1)"
29+
exit $fails

0 commit comments

Comments
 (0)