diff --git a/AGENTS.md b/AGENTS.md index 73140563c0..038c8c4c87 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,6 +24,7 @@ for it: | [docs/sceAudio.md](docs/sceAudio.md) | How the audio output calls block, how deep they buffer, and what each error means | | [docs/WebSocketDebugger.md](docs/WebSocketDebugger.md) | WebSocket debugger protocol reference | | [docs/reverse-engineering.md](docs/reverse-engineering.md) | Disassembling a firmware PRX with `--re-module`, to find out what the hardware actually does | +| [docs/adhoc-packet-decryption.md](docs/adhoc-packet-decryption.md) | Decrypting a sniffed capture of real-PSP ad hoc traffic (`Tools/adhoc_decrypt.py`, `adhoc_recover.c`) to compare hardware framing against PPSSPP's HLE-level ad hoc | | [docs/command-line.md](docs/command-line.md) | Adding a command-line option - the `g_autoParams` table, per-mode options, `ApplyToConfig()` | | [docs/patching-files.md](docs/patching-files.md) | Editing files from a script without wrecking the diff: line endings, and heredoc backslash escaping | | [docs/VSHBootInvestigation.md](docs/VSHBootInvestigation.md) | Booting the PSP's Visual Shell with `--vsh` against a real firmware dump | diff --git a/Tools/adhoc_decrypt.py b/Tools/adhoc_decrypt.py new file mode 100755 index 0000000000..a020d3d918 --- /dev/null +++ b/Tools/adhoc_decrypt.py @@ -0,0 +1,361 @@ +#!/usr/bin/env python3 +"""Decrypt and decode PSP ad hoc Wi-Fi traffic from a radiotap pcap capture. + +A real PSP encrypts its ad hoc link traffic, so a sniffed capture of two consoles playing is +ciphertext. This tool decrypts it and decodes the PDP/PTP/matching framing, which is handy for +comparing what a game does on hardware against what PPSSPP sends at the sceNetAdhoc HLE level. + + adhoc_decrypt.py CAPTURE.pcap # handshake + list the data frames + adhoc_decrypt.py CAPTURE.pcap --key HEX # decrypt (16-byte key) or decrypt+verify (20-byte) + adhoc_decrypt.py CAPTURE.pcap --known HEX16@CTR --known HEX16@CTR # recover the key, then decrypt + adhoc_decrypt.py --selftest + +The key is ephemeral (negotiated per ad hoc group membership by an ECDH handshake), so it is not in +the capture. But the data cipher has a known-plaintext weakness: with one known 16-byte plaintext +block for an even-counter frame and one for an odd-counter frame, --known recovers the 16-byte key +that decrypts everything (one AES op each, validated by the frame's own counter). The last 4 bytes of +the 20-byte key only feed the integrity tag; recover them with adhoc_recover.c if you want tags +verified. This is not a break of the key exchange - it just needs a capture that contains some known +plaintext (the ad hoc envelope's constant fields plus, e.g., a game's constant packet header or a +nickname you set). Capturing needs a Wi-Fi adapter in monitor mode on the consoles' ad hoc channel. + +The two fixed KIRK keys below ship in PPSSPP already (ext/libkirk/kirk_engine.c). Pure Python. +""" +import argparse +import hashlib +import struct +import sys +import zlib + +# ---- AES-128, pure Python ------------------------------------------------------------------ + +def _xt(a): + a <<= 1 + return (a ^ 0x11B) if a & 0x100 else a + +def _mul(a, b): + r = 0 + while b: + if b & 1: + r ^= a + a = _xt(a) + b >>= 1 + return r + +SBOX = [0] * 256 +INV = [0] * 256 +def _init_sbox(): + for i in range(256): + inv = 0 + if i: + for j in range(1, 256): + if _mul(i, j) == 1: + inv = j + break + x = inv + s = x ^ ((x << 1 | x >> 7) & 0xFF) ^ ((x << 2 | x >> 6) & 0xFF) ^ ((x << 3 | x >> 5) & 0xFF) ^ ((x << 4 | x >> 4) & 0xFF) ^ 0x63 + SBOX[i] = s + INV[s] = i +_init_sbox() + +def aes_expand(key): + w = [list(key[i:i + 4]) for i in range(0, 16, 4)] + rc = 1 + for i in range(4, 44): + t = list(w[i - 1]) + if i % 4 == 0: + t = [SBOX[b] for b in t[1:] + t[:1]] + t[0] ^= rc + rc = _xt(rc) + w.append([w[i - 4][j] ^ t[j] for j in range(4)]) + return [sum(w[r * 4:r * 4 + 4], []) for r in range(11)] + +def aes_enc(rk, b): + s = [b[i] ^ rk[0][i] for i in range(16)] + for r in range(1, 11): + s = [SBOX[x] for x in s] + s = [s[(i + 4 * (i % 4)) % 16] for i in range(16)] + if r != 10: + n = [] + for c in range(4): + a = s[c * 4:c * 4 + 4] + n += [_mul(a[0], 2) ^ _mul(a[1], 3) ^ a[2] ^ a[3], a[0] ^ _mul(a[1], 2) ^ _mul(a[2], 3) ^ a[3], + a[0] ^ a[1] ^ _mul(a[2], 2) ^ _mul(a[3], 3), _mul(a[0], 3) ^ a[1] ^ a[2] ^ _mul(a[3], 2)] + s = n + s = [s[i] ^ rk[r][i] for i in range(16)] + return bytes(s) + +def aes_dec(rk, b): + s = [b[i] ^ rk[10][i] for i in range(16)] + for r in range(9, -1, -1): + s = [s[(i - 4 * (i % 4)) % 16] for i in range(16)] + s = [INV[x] for x in s] + s = [s[i] ^ rk[r][i] for i in range(16)] + if r: + n = [] + for c in range(4): + a = s[c * 4:c * 4 + 4] + n += [_mul(a[0], 14) ^ _mul(a[1], 11) ^ _mul(a[2], 13) ^ _mul(a[3], 9), + _mul(a[0], 9) ^ _mul(a[1], 14) ^ _mul(a[2], 11) ^ _mul(a[3], 13), + _mul(a[0], 13) ^ _mul(a[1], 9) ^ _mul(a[2], 14) ^ _mul(a[3], 11), + _mul(a[0], 11) ^ _mul(a[1], 13) ^ _mul(a[2], 9) ^ _mul(a[3], 14)] + s = n + return bytes(s) + +def xor(a, b): + return bytes(x ^ y for x, y in zip(a, b)) + +def kirk7(key, data): + """KIRK command 7: AES-128-CBC decrypt, IV 0.""" + rk = aes_expand(key) + out, prev = b'', bytes(16) + for i in range(0, len(data) - len(data) % 16, 16): + c = data[i:i + 16] + out += xor(aes_dec(rk, c), prev) + prev = c + return out + +# Fixed KIRK 4/7 keys (already in ext/libkirk/kirk_engine.c keyvault). +KIRK_KEY_02 = bytes.fromhex('B813C35EC64441E3DC3C16F5B45E6484') # auth handshake messages +KIRK_KEY_44 = bytes.fromhex('7DF49265E3FAD678D6FE78ADBB3DFB63') # data keystream + +# The handshake's ECDH curve (same as KIRK's ec_p/ec_a/ec_b). +EC_P = 0xFFFFFFFFFFFFFFFF00000001FFFFFFFFFFFFFFFF +EC_A = EC_P - 3 +EC_B = 0xA68BEDC33418029C1D3CE33B9A321FCCBB9E0F0B + +def on_curve(pt): + x = int.from_bytes(pt[:20], 'big') + y = int.from_bytes(pt[20:40], 'big') + return (y * y - (x * x * x + EC_A * x + EC_B)) % EC_P == 0 + +# ---- data cipher --------------------------------------------------------------------------- +# +# A data frame is: 00 02 | counter(8, LE) | tag(16) | ciphertext. +# The counter counts 16-byte blocks sent in that direction, cumulative, from 0. +# Keystream: KIRK-7 (AES-CBC, IV 0, key 0x44) of consecutive blocks [C||half, C+1||half', ...], +# where half = key[0:8] on even block counters and key[8:16] on odd. Ciphertext = plaintext XOR it. +# Tag: first 16 bytes of SHA1(key20 || counter_header(16) || ciphertext). + +def keystream(key16, counter, nblocks): + blocks = b'' + for i in range(nblocks): + c = counter + i + blocks += struct.pack('H', pt[2:4])[0] + sp, dp = struct.unpack('>H', pt[4:6])[0], struct.unpack('>H', pt[6:8])[0] + if typ == 1: # PDP + seq = struct.unpack('>H', pt[8:10])[0] if len(pt) >= 10 else 0 + pay = pt[12:ln] if 12 < ln <= len(pt) else pt[12:] + s = 'PDP %04x->%04x seq %04x len %d' % (sp, dp, seq, ln) + if len(pay) >= 4 and pay[0] == 1: # matching message + op = pay[1] + mln = struct.unpack('>H', pay[2:4])[0] + s += ' | match op %d len %d %r' % (op, mln, bytes(pay[4:4 + min(mln, 16)])) + return s + if typ == 2: # PTP + flags = pt[1] + seq = struct.unpack('>I', pt[8:12])[0] if len(pt) >= 12 else 0 + name = _PTP_FLAGS.get(flags, '?') + s = 'PTP %04x->%04x %-7s seq %d len %d' % (sp, dp, name, seq, ln) + if flags in (1, 2, 3, 5) and len(pt) >= 16: + s += ' ack/wnd %d' % struct.unpack('>I', pt[12:16])[0] + return s + return 'type %02x len %d' % (typ, ln) + +# ---- pcap ---------------------------------------------------------------------------------- + +def read_frames(path): + f = open(path, 'rb').read() + magic = struct.unpack('= 4 and zlib.crc32(w[:-4]) & 0xFFFFFFFF != struct.unpack('> 2) & 3 != 2: + continue + hdr = 24 + (2 if (w[0] >> 4) & 8 else 0) + body = w[hdr:] + if body[:8] != bytes.fromhex('AAAA0300000088C8'): + continue + if t0 is None: + t0 = t + yield t - t0, w[10:16].hex(':'), w[4:10].hex(':'), body[8:] + +# ---- main ---------------------------------------------------------------------------------- + +def show_handshake(frames): + pubs = {} + for t, src, dst, b in frames: + if b[:2] != b'\x00\x01': + continue + kind, mtype, ln = b[2], b[3], struct.unpack('>H', b[4:6])[0] + print('%8.3f %s -> %s auth msg %02x/%02x len 0x%x' % (t, src, dst, kind, mtype, ln)) + if mtype == 2: + print(' (broadcast, plain) %s' % b[6:6 + ln].rstrip(b'\xff').hex()) + continue + pt = kirk7(KIRK_KEY_02, b[6:6 + ln]) + for i in range(0, len(pt), 32): + print(' %03x %s' % (i, pt[i:i + 32].hex())) + pubs.setdefault(src, pt[0x10:0x38]) + for src, pub in pubs.items(): + print('ephemeral public key of %s (msg+0x10): %s on curve: %s' % (src, pub.hex(), on_curve(pub))) + +def main(): + ap = argparse.ArgumentParser(description='Decrypt and decode PSP ad hoc traffic from a pcap.') + ap.add_argument('pcap', nargs='?') + ap.add_argument('--key', help='session key (hex): 16 bytes decrypts, 20 bytes also verifies tags') + ap.add_argument('--known', action='append', default=[], metavar='HEX16@COUNTER', + help='a known first-block plaintext (16 bytes) for the data frame at COUNTER; ' + 'give two of opposite counter parity to recover the 16-byte key') + ap.add_argument('--raw', action='store_true', help='show raw plaintext hex instead of decoded framing') + ap.add_argument('--selftest', action='store_true') + a = ap.parse_args() + + if a.selftest: + k = bytes(range(20)) + pt = b'\x01\x00\x00\x10\x77\x00\x77\x00ABCDSYN\x01' + fr = data_encrypt(k, 0x12, pt) + c, back, ok = data_decrypt(k, fr) + assert back == pt and ok and c == 0x12 + assert recover16([(c, fr[34:50], pt[:16]), + (c + 1, data_encrypt(k, c + 1, pt)[34:50], pt[:16])]) == k[:16] + print('selftest ok') + return + + if not a.pcap: + ap.error('a pcap is required (or --selftest)') + + frames = list(read_frames(a.pcap)) + print('%d 0x88C8 frames' % len(frames)) + show_handshake(frames) + + data = [f for f in frames if f[3][:2] == b'\x00\x02'] + print('\n%d data frames' % len(data)) + key = bytes.fromhex(a.key) if a.key else None + + if a.known: + byc = {} + for _, _, _, b in data: + byc.setdefault(struct.unpack(' %s ctr %5d tag %s %s' % (t, src[-5:], dst[-5:], counter, tagstr, info)) + + if not key: + # Without a key, show which counters both directions used (same keystream), as a hint for + # where to look for known plaintext: XOR of two such ciphertexts is XOR of their plaintexts. + same = [(c, v) for c, v in sorted(bycounter.items()) if len({s for s, _ in v}) > 1] + for t, src, dst, b in data[:30]: + print('%8.3f %s -> %s ctr %5d len %4d' % (t, src[-5:], dst[-5:], struct.unpack(' +#include +#include +#include +#include + +#ifdef USE_OPENSSL +#include +#include +static void aes_ecb_enc(const uint8_t *k, const uint8_t *in, uint8_t *out) { + AES_KEY ek; AES_set_encrypt_key(k, 128, &ek); AES_encrypt(in, out, &ek); +} +static void sha1_16(const uint8_t *in, size_t n, uint8_t *out20) { SHA1(in, n, out20); } +#else +#include +#include +static void aes_ecb_enc(const uint8_t *k, const uint8_t *in, uint8_t *out) { + size_t m; CCCrypt(kCCEncrypt, kCCAlgorithmAES, kCCOptionECBMode, k, 16, NULL, in, 16, out, 16, &m); +} +static void sha1_16(const uint8_t *in, size_t n, uint8_t *out20) { CC_SHA1(in, n, out20); } +#endif + +static const uint8_t KEY44[16] = {0x7D,0xF4,0x92,0x65,0xE3,0xFA,0xD6,0x78,0xD6,0xFE,0x78,0xAD,0xBB,0x3D,0xFB,0x63}; + +static int hx(const char *s, uint8_t *o, int n) { for (int i = 0; i < n; i++) if (sscanf(s + 2 * i, "%2hhx", &o[i]) != 1) return 0; return 1; } + +// Parse a 16-byte template "aabbxx..." into base bytes + the indices of the unknown ("xx") bytes. +static int parse_template(const char *s, uint8_t *base, int *unk, int *nunk) { + if (strlen(s) != 32) return 0; + *nunk = 0; + for (int i = 0; i < 16; i++) { + if (s[2 * i] == 'x' && s[2 * i + 1] == 'x') { base[i] = 0; unk[(*nunk)++] = i; } + else if (sscanf(s + 2 * i, "%2hhx", &base[i]) != 1) return 0; + } + return 1; +} + +static int do_half(const char *tmpl, const char *cths, const char *ctrs) { + uint8_t base[16], ct[16]; int unk[16], nunk; + if (!parse_template(tmpl, base, unk, &nunk) || !hx(cths, ct, 16)) { fprintf(stderr, "bad template/ct\n"); return 2; } + uint64_t counter = strtoull(ctrs, 0, 10); + if (nunk > 6) { fprintf(stderr, "too many unknown bytes (%d); brute space too large\n", nunk); return 2; } + uint64_t total = 1; for (int i = 0; i < nunk; i++) total <<= 8; + for (uint64_t v = 0; v < total; v++) { + uint8_t pt[16]; memcpy(pt, base, 16); + uint64_t t = v; for (int i = 0; i < nunk; i++) { pt[unk[i]] = t & 0xff; t >>= 8; } + uint8_t x[16], blk[16]; + for (int i = 0; i < 16; i++) x[i] = ct[i] ^ pt[i]; + aes_ecb_enc(KEY44, x, blk); + uint64_t c; memcpy(&c, blk, 8); + if (c == counter) { + printf("half: "); for (int i = 8; i < 16; i++) printf("%02x", blk[i]); + printf(" (plaintext "); for (int i = 0; i < 16; i++) printf("%02x", pt[i]); printf(")\n"); + return 0; + } + } + printf("no match (wrong template or ciphertext)\n"); + return 1; +} + +static uint8_t g_key[20], g_ct[16], g_want[16]; static uint64_t g_ctr; +static volatile int g_found; static uint32_t g_tail; +static void *tagworker(void *arg) { + long id = (long)arg; + uint8_t buf[52]; memcpy(buf, g_key, 16); + uint8_t hdr[16] = {0}; memcpy(hdr, &g_ctr, 8); memcpy(buf + 20, hdr, 16); memcpy(buf + 36, g_ct, 16); + for (uint64_t x = id; x < 0x100000000ULL && !g_found; x += 8) { + buf[16] = x; buf[17] = x >> 8; buf[18] = x >> 16; buf[19] = x >> 24; + uint8_t h[20]; sha1_16(buf, 52, h); + if (!memcmp(h, g_want, 16)) { g_tail = (uint32_t)x; g_found = 1; return 0; } + } + return 0; +} + +static int do_tag(const char *k16, const char *cths, const char *ctrs, const char *tags) { + if (!hx(k16, g_key, 16) || !hx(cths, g_ct, 16) || !hx(tags, g_want, 16)) { fprintf(stderr, "bad hex\n"); return 2; } + g_ctr = strtoull(ctrs, 0, 10); + pthread_t t[8]; for (long i = 0; i < 8; i++) pthread_create(&t[i], 0, tagworker, (void *)i); + for (int i = 0; i < 8; i++) pthread_join(t[i], 0); + if (!g_found) { printf("tag tail not found (wrong key or frame)\n"); return 1; } + printf("key20: "); for (int i = 0; i < 16; i++) printf("%02x", g_key[i]); + printf("%02x%02x%02x%02x\n", g_tail & 0xff, (g_tail >> 8) & 0xff, (g_tail >> 16) & 0xff, (g_tail >> 24) & 0xff); + return 0; +} + +int main(int argc, char **argv) { + if (argc == 5 && !strcmp(argv[1], "half")) return do_half(argv[2], argv[3], argv[4]); + if (argc == 6 && !strcmp(argv[1], "tag")) return do_tag(argv[2], argv[3], argv[4], argv[5]); + fprintf(stderr, + "usage:\n" + " %s half TEMPLATE16hex(xx=unknown) CT16hex COUNTER\n" + " %s tag KEY16hex CT16hex COUNTER TAG16hex\n", argv[0], argv[0]); + return 2; +} diff --git a/docs/adhoc-packet-decryption.md b/docs/adhoc-packet-decryption.md new file mode 100644 index 0000000000..344595bef6 --- /dev/null +++ b/docs/adhoc-packet-decryption.md @@ -0,0 +1,110 @@ +# Decrypting captured PSP ad hoc traffic + +A real PSP encrypts its ad hoc Wi-Fi link, so a sniffed capture of two consoles playing is +ciphertext. `Tools/adhoc_decrypt.py` decrypts it and decodes the PDP/PTP/matching framing. This is a +debugging aid: it lets you compare, byte for byte and in timing, what a game actually sends on +hardware against what PPSSPP sends for the same game at the `sceNetAdhoc` HLE level. It is **not** +needed to run ad hoc multiplayer in PPSSPP (PPSSPP never encrypts; it works at the HLE layer), and it +has nothing to do with cross-play - a real PSP will not accept an emulator as a peer, because the +handshake is signed with a per-console key. + +The two fixed keys the tool uses already ship in PPSSPP (`ext/libkirk/kirk_engine.c`). The per-session +key is not one of them (see below). + +## Capturing + +Ad hoc is peer-to-peer, so there is no access point to join - you need a Wi-Fi interface in **monitor +mode** on the consoles' ad hoc channel. + +- Set both PSPs to a fixed ad hoc channel (Settings > Network Settings), so you know where to listen. +- Capture raw 802.11 on that channel to a pcap. On macOS, Wireless Diagnostics > Sniffer writes one to + `/var/tmp`; on Linux, `iw`/`airmon-ng` to put a card in monitor mode, then `tcpdump`/Wireshark. The + capture must include the radiotap header. +- Not every Wi-Fi chip captures other devices' ad hoc frames in monitor mode; if a capture of a known + exchange has no `0x88C8` frames between the two PSP MACs, the adapter can't see them. + +PPSSPP's ad hoc traffic is **not** encrypted (it is plain UDP/TCP on the relay or P2P), so this tool is +only for captures of real hardware. + +## Decrypting with a known key + + Tools/adhoc_decrypt.py CAPTURE.pcap --key HEX + +A 16-byte key decrypts; a 20-byte key also verifies the integrity tags. Output is one line per data +frame with the decoded envelope, e.g. + + 1.334 57:3f -> e6:7c ctr 7 tag - PTP 2300->2300 SYN seq 493060044 len 16 ack/wnd 8192 + 1.474 57:3f -> e6:7c ctr 16 tag - PTP 2300->2300 DATA seq 0 len 1012 + 1.481 e6:7c -> 57:3f ctr 21 tag - PTP 2300->2300 WND-ACK seq 0 len 16 ack/wnd 7192 + +Pass `--raw` for plaintext hex instead of the decode. With no `--key`, it decodes the auth handshake, +lists the data frames, and reports which counters both directions used (a hint for key recovery). + +## Recovering the key from the capture + +The session key is ephemeral - negotiated per ad hoc group membership by an ECDH handshake - so it is +not in the capture, and it is not one of the fixed KIRK keys. But the data cipher has a known-plaintext +weakness, so a capture that contains some guessable plaintext is enough to recover the key, no console +memory needed. (This is not a break of the key exchange.) + +Each keystream block is `AES(counter || half-of-key)`, so given a known 16-byte first-block plaintext, +one AES operation yields 8 key bytes, and the frame's own counter (in its cleartext header) confirming +the guess is a 64-bit check. One even-counter frame and one odd-counter frame give the 16-byte key. + +**If you know a full 16-byte plaintext block** (pure Python, instant): + + Tools/adhoc_decrypt.py CAPTURE.pcap --known HEX16@COUNTER --known HEX16@COUNTER + +Give one `--known` for an even counter and one for an odd counter. It prints the 16-byte key and +decrypts. + +**If part of that block is unknown** (e.g. the sequence number), brute the unknown bytes with the C +helper, which is much faster than Python: + + cc -O3 Tools/adhoc_recover.c -o adhoc_recover # macOS; Linux: add -DUSE_OPENSSL -lcrypto + ./adhoc_recover half TEMPLATE16 CT16 COUNTER # "xx" marks each unknown byte (<= ~5) + +`TEMPLATE16` is the 32-hex-char first-block plaintext with `xx` for each unknown byte; `CT16` is that +frame's first 16 ciphertext bytes (from the tool's `--raw`/listing). Run it for an even- and an +odd-counter frame and concatenate the two `half:` outputs (even first) to get the 16-byte key. + +**What you can usually know.** The ad hoc envelope has constant fields in every frame regardless of +game: byte 0 is the type, byte 1 is `00` (PDP) or a flags byte (PTP), and for PDP bytes 10-11 are +`0000`; the length field is pinned to a 16-byte range by the ciphertext size. The remaining unknowns +are the **ports**, the **sequence**, and the first payload bytes. The cheap way to learn the ports (and +any constant packet header) for a specific game is to run that game in PPSSPP once and note which ports +it opens (`sceNetAdhocPdpCreate` / `sceNetAdhocPtpOpen` in the log) - they are the same on hardware. +With the ports known, only the small sequence field is left to brute, so any past or future capture of +that game then cracks at once. + +**Tag verification** (optional) needs the last 4 bytes of the 20-byte key, which only feed the tag. +Brute them once you have the 16-byte key: + + ./adhoc_recover tag KEY16 CT16 COUNTER TAG16 # ~15 s; prints the 20-byte key + +## Frame format reference + +802.11 data frames, LLC/SNAP, ethertype `0x88C8`. The `0x88C8` payload starts with a 2-byte kind: + +- `00 01` **auth handshake** - AES-CBC under a fixed KIRK key, so it always decodes. Carries each + side's ephemeral ECDH public key and its console certificate; this is what establishes the session + key and what makes the link console-authenticated. +- `00 02` **data** - `00 02 | counter(8, little-endian) | tag(16) | ciphertext`. The counter counts + 16-byte blocks sent in that direction, cumulative from 0. The tag is the first 16 bytes of + `SHA1(key20 || counter-header(16) || ciphertext)`. + +The decrypted data payload is a PDP or PTP frame. Both start the same: `type(1) | flags-or-00(1) | +length(2, big-endian) | source port(2, BE) | dest port(2, BE)`. + +- **PDP** (type `01`): then a 16-bit sequence and `00 00`, then the datagram. A matching-library + message rides in that payload as `01 | opcode(1) | length(2, BE) | data`. +- **PTP** (type `02`): byte 1 is a flags byte (SYN / SYN-ACK / ACK / DATA / window-ACK), then a 32-bit + sequence; control frames carry a 32-bit acknowledgement / advertised receive window after it, data + frames carry the payload. It is a TCP-like reliable stream, so a capture shows the three-way + handshake and the receive window changing as a buffer fills or drains. + +## See also + +- [adhoc-multiplayer-testing.md](adhoc-multiplayer-testing.md) - playing a game's ad hoc multiplayer + with several headless PPSSPP instances, to produce the HLE-level side of a hardware-vs-emulator + comparison.