Files
rebbarb/scripts/test-analyse-pcap.py
T
Roflin 4e7a2ef429 analyse-pcap: count UDP, because ROM streaming is UDP/FSP not TCP
The 850 KB/s dump measurement does not bound ROM streaming, and the tool could
not have measured streaming at all: it only counted TCP payload, so a
streaming capture would have reported ~zero throughput.

Swiss's ROM streaming runs in the game via the patch driver's own stack
(cube/patches/bba/tcpip.c), which contains no TCP whatsoever -- UDP + FSP,
plus ICMP and ARP.  Reads are sized to the path MTU rather than 1024:

  _fsp.data_length = MIN(length, env->pmtu - (fsp->data - eth->data));

so ~1446 B of payload per datagram at a 1500 MTU, with QUEUE_SIZE 2
outstanding and udp->checksum = 0x0000 (no payload checksum).  None of the
menu path's TCP window, lwIP overhead or 1024-byte writes apply.

The analyser now counts UDP payload and picks the bulk direction by which way
the payload actually flows -- streaming is PC -> GC, the opposite of a disc
dump -- with --bulk to_gc to steer the gap analysis.  Self-test H replays the
streaming shape (1446 B datagrams, 2 per request) and checks the bulk
direction is identified and the payload counted.

Recorded in the TODO that "can it stream?" is still OPEN, but open on the GC's
side: the dump established the adapter has headroom (zero retransmissions,
~17% W5100 bus utilisation, 26% of the EXI ceiling).  The hard ceiling is
3375 KB/s and the GC's disc delivers ~2000-3100 KB/s, so full disc rate has no
margin -- but games read in bursts, not continuously at peak.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 15:32:45 +02:00

197 lines
7.6 KiB
Python
Executable File

#!/usr/bin/env python3
"""Self-test for analyse-pcap.py: synthesise a capture with known properties
and check the tool reports them."""
import os, socket, struct, subprocess, sys, tempfile
GC = socket.inet_aton("10.0.0.64")
PC = socket.inet_aton("10.0.0.1")
MAC_GC = bytes.fromhex("0009bf000001")
MAC_PC = bytes.fromhex("002b67d4156c")
def tcp_frame(src, dst, smac, dmac, seq, payload, win=4096):
tcp = struct.pack("!HHIIBBHHH", 4000, 21, seq, 0, 5 << 4, 0x10, win, 0, 0)
ip = struct.pack("!BBHHHBBH4s4s", 0x45, 0, 20 + len(tcp) + len(payload),
0, 0, 64, socket.IPPROTO_TCP, 0, src, dst)
return dmac + smac + b"\x08\x00" + ip + tcp + payload
def write_pcap(path, pkts):
with open(path, "wb") as f:
f.write(struct.pack("<IHHiIII", 0xA1B2C3D4, 2, 4, 0, 0, 65535, 1))
for ts, data in pkts:
s, us = int(ts), int(round((ts - int(ts)) * 1e6))
f.write(struct.pack("<IIII", s, us, len(data), len(data)))
f.write(data)
def run(path, *args):
r = subprocess.run([sys.executable, "-I", "scripts/analyse-pcap.py", path,
"--gc-ip", "10.0.0.64", *args],
capture_output=True, text=True)
if r.returncode:
print(r.stdout, r.stderr)
raise SystemExit(f"analyser failed: {r.returncode}")
return r.stdout
fails = []
tmp = tempfile.mkdtemp()
# Scenario A: 1000 full frames in 2 s from the GC, no retransmits.
# 1000 * 1514 B / 2 s = 757 KB/s -- deliberately the speed being measured.
pkts, seq, t = [], 1, 1000.0
for i in range(1000):
pkts.append((t, tcp_frame(GC, PC, MAC_GC, MAC_PC, seq, b"\0" * 1460)))
seq += 1460
t += 0.002
pA = os.path.join(tmp, "a.pcap")
write_pcap(pA, pkts)
out = run(pA)
if "1000 frames" not in out:
fails.append("A: frame count")
if "mean frame 1514" not in out:
fails.append(f"A: mean frame size not 1514\n{out}")
if "retransmissions 0" not in out:
fails.append("A: should report no retransmissions")
# 1514*1000/1.998s = 757 KB/s, ~22% of the EXI ceiling
if "% of the 27 Mbit/s EXI ceiling" not in out:
fails.append("A: no EXI ceiling comparison")
pct = float(out.split("% of the 27 Mbit/s")[0].split(",")[-1].strip())
if not (20 <= pct <= 25):
fails.append(f"A: EXI percentage {pct}, expected ~22")
else:
print(f"A 1000x1514B/2s -> reported {pct}% of the EXI ceiling")
# Scenario B: small frames -- must trigger the per-frame-overhead warning.
pkts, seq, t = [], 1, 2000.0
for i in range(1000):
pkts.append((t, tcp_frame(GC, PC, MAC_GC, MAC_PC, seq, b"\0" * 200)))
seq += 200
t += 0.002
pB = os.path.join(tmp, "b.pcap")
write_pcap(pB, pkts)
out = run(pB)
if "PER-FRAME overhead" not in out:
fails.append(f"B: small frames did not trigger the overhead warning\n{out}")
else:
print("B 200B payloads -> per-frame-overhead warning raised")
# Scenario C: retransmissions (same seq+len twice) must be counted.
pkts, t = [], 3000.0
for i in range(10):
pkts.append((t, tcp_frame(GC, PC, MAC_GC, MAC_PC, 1, b"\0" * 1460)))
t += 0.002
pC = os.path.join(tmp, "c.pcap")
write_pcap(pC, pkts)
out = run(pC)
if "retransmissions 9" not in out:
fails.append(f"C: expected 9 retransmissions\n{out}")
else:
print("C same seq x10 -> 9 retransmissions counted")
# Scenario D: a long stall must be found and attributed.
pkts, seq, t = [], 1, 4000.0
for i in range(100):
pkts.append((t, tcp_frame(GC, PC, MAC_GC, MAC_PC, seq, b"\0" * 1460)))
seq += 1460
t += 0.3 if i == 50 else 0.001 # one 300 ms hole
pD = os.path.join(tmp, "d.pcap")
write_pcap(pD, pkts)
out = run(pD)
if "300.00 ms" not in out:
fails.append(f"D: the 300 ms gap was not reported\n{out}")
elif "NOT sending" not in out:
fails.append(f"D: dominant stall not attributed\n{out}")
else:
print("D one 300ms hole in 100 frames -> found and attributed")
# Scenario E: zero-window from the GC means the GC cannot drain.
pkts, t = [], 5000.0
pkts.append((t, tcp_frame(PC, GC, MAC_PC, MAC_GC, 1, b"\0" * 100, win=0)))
pkts.append((t + 0.05, tcp_frame(PC, GC, MAC_PC, MAC_GC, 2, b"\0" * 100, win=4096)))
pE = os.path.join(tmp, "e.pcap")
write_pcap(pE, pkts)
out = run(pE)
if "zero-window" not in out or "CPU/stack bound" not in out:
fails.append(f"E: zero-window not reported\n{out}")
else:
print("E zero-window span -> reported as CPU/stack bound")
# Scenario F: pcapng must be refused with a useful message, not misparsed.
pF = os.path.join(tmp, "f.pcapng")
with open(pF, "wb") as f:
f.write(struct.pack(">I", 0x0A0D0D0A) + b"\0" * 64)
r = subprocess.run([sys.executable, "-I", "scripts/analyse-pcap.py", pF],
capture_output=True, text=True)
if "pcapng" not in (r.stdout + r.stderr):
fails.append("F: pcapng not detected")
else:
print("F pcapng refused with a conversion hint")
# Scenario G: window x RTT bound -- 2 undersized segments per ACK, which is
# the real shape measured on hardware (1024 B segments, 2920 B window).
# The tool must call it window-bound, and must warn that RX interrupt
# COALESCING would make it worse rather than better.
pkts, seq, t = [], 1, 6000.0
for i in range(200):
pkts.append((t, tcp_frame(GC, PC, MAC_GC, MAC_PC, seq, b"\0" * 1024, win=2920)))
seq += 1024
t += 0.0002
pkts.append((t, tcp_frame(GC, PC, MAC_GC, MAC_PC, seq, b"\0" * 1024, win=2920)))
seq += 1024
t += 0.0002
pkts.append((t, tcp_frame(PC, GC, MAC_PC, MAC_GC, 1 + i, b"", win=65535)))
t += 0.0024 # ~2.5 ms per round trip
pG = os.path.join(tmp, "g.pcap")
write_pcap(pG, pkts)
out = run(pG, "--gc-ip", "10.0.0.64")
if "2.0 data frames per ACK" not in out:
fails.append(f"G: frames-per-ACK not derived\n{out}")
elif "WINDOW x RTT BOUND" not in out:
fails.append(f"G: window-bound flow not identified\n{out}")
elif "COALESCING would make this WORSE" not in out:
fails.append(f"G: missing the coalescing counter-warning\n{out}")
elif "full-MSS segments" not in out:
fails.append(f"G: did not suggest full-MSS segments\n{out}")
else:
print("G 1024B segments + 2920B window -> window x RTT bound,")
print(" full-MSS suggested, coalescing warned against")
def udp_frame(src, dst, smac, dmac, payload):
udp = struct.pack("!HHHH", 7717, 7717, 8 + len(payload), 0) + payload
ip = struct.pack("!BBHHHBBH4s4s", 0x45, 0, 20 + len(udp), 0, 0, 64,
socket.IPPROTO_UDP, 0, src, dst)
return dmac + smac + b"\x08\x00" + ip + udp
# Scenario H: ROM streaming shape -- UDP/FSP, bulk flowing PC -> GC, with
# small requests going the other way. Before UDP was counted this reported
# zero throughput, because Swiss's in-game stack has no TCP at all.
pkts, t = [], 7000.0
for i in range(300):
pkts.append((t, udp_frame(GC, PC, MAC_GC, MAC_PC, b"\0" * 20))) # request
t += 0.0001
for _ in range(2): # 2 queued
pkts.append((t, udp_frame(PC, GC, MAC_PC, MAC_GC, b"\0" * 1446)))
t += 0.0002
t += 0.0008
pH = os.path.join(tmp, "h.pcap")
write_pcap(pH, pkts)
out = run(pH, "--bulk", "to_gc")
if "bulk direction: PC -> GC" not in out:
fails.append(f"H: bulk direction not detected as PC->GC\n{out}")
elif "UDP" not in out:
fails.append(f"H: UDP frames not counted\n{out}")
else:
# 600 x 1446 B over ~0.39 s should be well above 1 MB/s
kbs = float(out.split("throughput")[2].split("KB/s")[0].strip())
if kbs < 500:
fails.append(f"H: UDP payload undercounted, {kbs} KB/s")
else:
print(f"H UDP/FSP streaming shape -> bulk PC->GC, {kbs:.0f} KB/s")
print("\nFAILURES:\n " + "\n ".join(fails) if fails else
"\nAll analyse-pcap self-tests passed.")
sys.exit(1 if fails else 0)