#!/usr/bin/env python3 # SPDX-License-Identifier: MIT # SPDX-FileCopyrightText: 2026 Martino Ferrari """Converts a udpscope packet capture to a classic pcap file. $ tools/capture_to_pcap.py ~/.cache/udpscope/192.168.1.40-5000/capture.bin out.pcap 1000 slots, 412 stored, 0 torn, 0 truncated -> 412 packets, 26.1 MiB $ wireshark out.pcap The capture is udpscope's ring of the last N datagrams exactly as recv() returned them (see docs/superpowers/specs/2026-10-09-udp-packet-capture-design.md): a 64-byte header, then fixed-size slots whose trailing `seq` word is written last, so a slot carrying a non-zero `seq` is complete even after a crash. This script walks the slots, orders them by `seq`, skips the incomplete ones, and wraps every record in a synthesised Ethernet/IPv4/UDP header built from the capture header's peer address and ports -- pure standard library, no numpy, so it runs wherever the self-check runs. The counts it prints to stderr are a diagnostic in their own right: `torn` or `truncated` above zero is a finding about the capture before anything about the stream. """ import struct import sys MAGIC = b"UDPSCAP\x00" HEADER_BYTES = 64 RECORD_FIXED = 16 # t_arrival f64, len u32, orig_len u32 SEQ_BYTES = 8 PCAP_MAGIC = 0xA1B23C4D # microsecond-resolution, writing the non-nanosecond classic LINKTYPE_ETHERNET = 1 def fail(msg): print(f"capture_to_pcap: {msg}", file=sys.stderr) sys.exit(2) def main(argv): if len(argv) != 3: fail(f"usage: {argv[0]} capture.bin out.pcap") src, dst = argv[1], argv[2] with open(src, "rb") as f: blob = f.read() if len(blob) < HEADER_BYTES: fail(f"{src}: {len(blob)} bytes, smaller than a capture header") magic, version, slot_count, slot_bytes, cursor = struct.unpack_from("<8sIIII", blob, 0) (t0,) = struct.unpack_from(" payload_bytes: truncated += 1 records.append((seq, t_arrival, slot[RECORD_FIXED : RECORD_FIXED + length])) payload_sum += length stored += 1 records.sort(key=lambda r: r[0]) ip = bytes([(peer_addr >> 24) & 0xFF, (peer_addr >> 16) & 0xFF, (peer_addr >> 8) & 0xFF, peer_addr & 0xFF]) zero_ip = b"\x00\x00\x00\x00" with open(dst, "wb") as out: out.write(struct.pack(" 1e6 else t0 + t_arrival sec = int(ts) usec = int(round((ts - sec) * 1e6)) if usec >= 1_000_000: sec, usec = sec + 1, usec - 1_000_000 udp_len = 8 + len(payload) udp = struct.pack(">HHHH", peer_port, local_port, udp_len, 0) + payload total_len = 20 + udp_len ip_hdr = struct.pack( ">BBHHHBBH4s4s", 0x45, # version 4, IHL 5 0, # DSCP total_len, seq & 0xFFFF, # id: the capture's own ordinal, which is what a # duplicate datagram would show as a repeated id 0, # flags/offset 64, 17, # TTL, proto UDP 0, # checksum: wireshark recomputes and says "incorrect", # which is honest for a synthesised header zero_ip, # source: the capture is on the receiver, so the local # address is unknown to it ip, # destination: the source we captured from ) frame = b"\x00" * 6 + b"\x01" * 6 + b"\x08\x00" + ip_hdr + udp out.write(struct.pack(" {len(records)} packets, {mib:.1f} MiB, " f"payload {payload_sum} bytes", file=sys.stderr, ) if __name__ == "__main__": main(sys.argv)