Files
rebbarb/scripts/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

266 lines
11 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Work out what is limiting BBA throughput, from a capture.
sudo tcpdump -i enp0s31f6 -s0 -w /tmp/bba.pcap host 10.0.0.64
# ... run the Swiss dump for ~20 s, then Ctrl-C ...
./scripts/analyse-pcap.py /tmp/bba.pcap --gc-ip 10.0.0.64
Answers, per direction:
* throughput, and what fraction of the 27 Mbit/s EXI ceiling it is
* MEAN FRAME SIZE -- if this is far below 1514 the cost is per-frame
overhead (interrupts, EXI transactions, socket bookkeeping), not bandwidth
* retransmissions -- frames we dropped would show up here, e.g. from the
15-page RX ring overflowing (RBFI)
* the GC's advertised TCP window, and how long it sat at zero
* the largest gaps in the stream, which is where the stall actually is
Classic pcap only (what `tcpdump -w` writes). Stdlib only, no root needed.
"""
import argparse
import collections
import socket
import struct
import sys
PCAP_MAGICS = {0xA1B2C3D4: (">", 1_000_000), 0xD4C3B2A1: ("<", 1_000_000),
0xA1B23C4D: (">", 1_000_000_000), 0x4D3CB2A1: ("<", 1_000_000_000)}
EXI_BYTES_PER_S = 27_000_000 / 8 # the hard ceiling: 27 MHz, 1 bit/clock
def packets(path):
with open(path, "rb") as f:
hdr = f.read(24)
if len(hdr) < 24:
sys.exit("not a pcap file (too short)")
magic = struct.unpack(">I", hdr[:4])[0]
if magic == 0x0A0D0D0A:
sys.exit("this is pcapng; re-capture with `tcpdump -w ... ` "
"(libpcap writes classic pcap) or convert with "
"`editcap -F pcap in.pcapng out.pcap`")
if magic not in PCAP_MAGICS:
sys.exit(f"unknown pcap magic {magic:#010x}")
endian, divisor = PCAP_MAGICS[magic]
linktype = struct.unpack(endian + "I", hdr[20:24])[0]
if linktype != 1:
sys.exit(f"linktype {linktype}, expected 1 (Ethernet)")
while True:
ph = f.read(16)
if len(ph) < 16:
return
ts_s, ts_f, incl, orig = struct.unpack(endian + "IIII", ph)
data = f.read(incl)
if len(data) < incl:
return
yield ts_s + ts_f / divisor, data, orig
def main():
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("pcap")
ap.add_argument("--gc-ip", default="10.0.0.64")
ap.add_argument("--top-gaps", type=int, default=5)
ap.add_argument("--bulk", choices=("auto", "from_gc", "to_gc"),
default="auto",
help="which direction carries the payload; 'auto' guesses "
"GC->PC (a disc dump). ROM streaming is to_gc.")
a = ap.parse_args()
gc = socket.inet_aton(a.gc_ip)
stats = {d: dict(pkts=0, wire=0, payload=0, retx=0, sizes=[], zerowin=0)
for d in ("from_gc", "to_gc")}
# Which direction carries the bulk is not knowable until the end, so the
# gap analysis needs a hint up front. Two passes would be cleaner; one
# pass with a hint is enough, and the hint only affects which direction's
# inter-frame gaps get recorded.
bulk_hint = a.bulk if a.bulk != "auto" else "from_gc"
seen = {d: set() for d in ("from_gc", "to_gc")}
t0 = t1 = None
wins, gaps, last_ts = [], [], None
zero_win_spans, zw_start = [], None
other = collections.Counter()
for ts, frame, orig in packets(a.pcap):
if len(frame) < 34:
continue
etype = struct.unpack("!H", frame[12:14])[0]
if etype != 0x0800:
other[etype] += 1
continue
ip = frame[14:]
ihl = (ip[0] & 0x0F) * 4
src, dst = ip[12:16], ip[16:20]
if src == gc:
d = "from_gc"
elif dst == gc:
d = "to_gc"
else:
continue
if t0 is None:
t0 = ts
t1 = ts
s = stats[d]
s["pkts"] += 1
s["wire"] += orig
s["sizes"].append(orig)
if ip[9] == socket.IPPROTO_UDP:
# ROM streaming (Swiss's in-game patch driver) is UDP + FSP, with
# no TCP anywhere -- so without this the tool reports a streaming
# capture as zero throughput.
udp = ip[ihl:]
if len(udp) >= 8:
ulen = struct.unpack("!H", udp[4:6])[0]
s["payload"] += max(ulen - 8, 0)
s["udp"] = s.get("udp", 0) + 1
if d == "from_gc" or d == "to_gc":
pass
if d == bulk_hint:
if last_ts is not None:
gaps.append((ts - last_ts, ts))
last_ts = ts
continue
if ip[9] != socket.IPPROTO_TCP:
continue
tcp = ip[ihl:]
if len(tcp) < 20:
continue
sport, dport, seq = struct.unpack("!HHI", tcp[:8])
doff = (tcp[12] >> 4) * 4
win = struct.unpack("!H", tcp[14:16])[0]
plen = struct.unpack("!H", ip[2:4])[0] - ihl - doff
if plen > 0:
s["payload"] += plen
key = (sport, dport, seq, plen)
if key in seen[d]:
s["retx"] += 1
else:
seen[d].add(key)
# Gaps measured on the DATA direction only: a gap between data
# frames is a stall, a gap before an ACK is just latency.
if d == "from_gc":
if last_ts is not None:
gaps.append((ts - last_ts, ts))
last_ts = ts
if d == "from_gc":
wins.append(win)
if d == "to_gc":
if win == 0 and zw_start is None:
zw_start = ts
elif win != 0 and zw_start is not None:
zero_win_spans.append(ts - zw_start)
zw_start = None
if t0 is None:
sys.exit(f"no IPv4 traffic involving {a.gc_ip} in the capture")
dur = max(t1 - t0, 1e-9)
print(f"capture: {dur:.2f} s involving {a.gc_ip}\n")
for d, label in (("from_gc", "GC -> PC (disc dump)"), ("to_gc", "PC -> GC")):
s = stats[d]
if not s["pkts"]:
print(f"{label}: nothing\n")
continue
mean = s["wire"] / s["pkts"]
bps = s["wire"] / dur
pps = s["pkts"] / dur
print(f"{label}")
print(f" {s['pkts']} frames, {s['wire']/1e6:.2f} MB on the wire, "
f"{s['payload']/1e6:.2f} MB TCP payload")
print(f" throughput {bps/1024:.0f} KB/s ({bps*8/1e6:.2f} Mbit/s, "
f"{100*bps/EXI_BYTES_PER_S:.1f}% of the 27 Mbit/s EXI ceiling)")
print(f" {pps:.0f} frames/s, mean frame {mean:.0f} B "
f"(max {max(s['sizes'])} B)")
if s["payload"]:
print(f" retransmissions {s['retx']} "
f"({100*s['retx']/max(s['pkts'],1):.2f}% of frames)")
if mean < 1000 and s["payload"]:
print(f" ⚠️ mean frame {mean:.0f} B is well under 1514: the cost "
f"is PER-FRAME overhead,")
print(f" not bandwidth. {pps:.0f} frames/s each needing an "
f"interrupt + EXI")
print(f" transactions + W5100 socket bookkeeping.")
print()
# ── Is the bulk direction window x RTT bound? ────────────────────────
#
# THE decisive statistic, and the one that is easy to miss: if the sender
# can only have `window` bytes unacknowledged, throughput is
# window / RTT no matter how fast the wire or the adapter is. Derive the
# round trip from the capture itself -- one ACK is one round trip -- so
# this needs no ping and no assumptions.
# Bulk = whichever direction actually moved the payload.
bulk = max(("from_gc", "to_gc"), key=lambda d: stats[d]["payload"])
back = "to_gc" if bulk == "from_gc" else "from_gc"
data_pkts = stats[bulk]["pkts"]
acks = stats[back]["pkts"]
payload = stats[bulk]["payload"]
if payload:
print(f"\nbulk direction: {'GC -> PC' if bulk == 'from_gc' else 'PC -> GC'}"
f" ({stats[bulk].get('udp', 0)} UDP, "
f"{data_pkts - stats[bulk].get('udp', 0)} TCP frames)")
if acks and payload:
per_rt = payload / acks
rtt = dur / acks
print(f"round-trip analysis")
print(f" {data_pkts/acks:.1f} data frames per ACK, "
f"{per_rt:.0f} B per round trip, "
f"implied RTT {rtt*1000:.2f} ms")
print(f" {per_rt/rtt/1024:.0f} KB/s = bytes-per-round-trip / RTT"
f" <- compare with the measured throughput above")
if wins:
w = max(wins)
seg = payload / data_pkts
print(f" GC advertised window {w} B; segments average "
f"{seg:.0f} B, so {w//max(int(seg),1)} fit in flight")
if per_rt <= w * 1.2:
print(f" ⚠️ WINDOW x RTT BOUND: {per_rt:.0f} B in flight "
f"against a {w} B window.")
print(f" The adapter is not the limit -- the sender "
f"cannot have more outstanding.")
print(f" Levers, in order of effect:")
print(f" 1. a larger TCP send buffer / window on the "
f"GC (lwIP TCP_SND_BUF)")
if seg < 1400:
full = w - (w % 1460) or 1460
print(f" 2. full-MSS segments: {seg:.0f} B "
f"segments waste the window;")
print(f" {full} B per round trip would give "
f"{full/rtt/1024:.0f} KB/s ({full/per_rt:.2f}x)")
print(f" 3. a shorter round trip -- throughput scales "
f"1/RTT")
print(f" ⚠️ RX interrupt COALESCING would make this "
f"WORSE, not better:")
print(f" delaying the ACK's interrupt lengthens the "
f"round trip, and")
print(f" a window-bound flow pays for that directly.")
if wins:
print(f"\nGC advertised TCP window: min {min(wins)} max {max(wins)} "
f"last {wins[-1]} B")
if zero_win_spans:
print(f"PC saw zero-window from the GC {len(zero_win_spans)} times, "
f"total {sum(zero_win_spans)*1000:.0f} ms "
f"({100*sum(zero_win_spans)/dur:.1f}% of the capture)")
print(" -> the GC could not drain fast enough: CPU/stack bound")
if other:
print("non-IPv4 frames: " +
", ".join(f"{e:#06x}×{n}" for e, n in other.most_common(4)))
if gaps:
gaps.sort(reverse=True)
total_stall = sum(g for g, _ in gaps if g > 0.005)
print(f"\nlargest gaps between GC data frames "
f"(top {a.top_gaps}):")
for g, ts in gaps[:a.top_gaps]:
print(f" {g*1000:7.2f} ms at t+{ts-t0:.3f}s")
print(f" gaps >5 ms total {total_stall*1000:.0f} ms "
f"({100*total_stall/dur:.1f}% of the capture)")
if total_stall / dur > 0.2:
print(" -> most of the time is spent NOT sending: the limit is "
"upstream of the wire")
print(" (disc read, CPU, or stack), not the adapter")
if __name__ == "__main__":
main()