Independent documentation of VRChat's Photon transport, recovered from static analysis of the 2026.3.3p2 Android client (libil2cpp.so, Unity 6000.0.67f1-DWR) and from AES-GCM–verified wire captures. Photon client version on the wire is 5.1.17.0. Serialization is GpBinaryV18.
HTTPS api.vrchat.cloud login, config, instance reservation (join token / PhotonToken)
WSS ns.photonengine.io NameServer: GetRegions, AuthenticateOnce → AES-256-GCM secret
UDP master :27001/5055 JoinGame(226) → game-server address + token
UDP game :27002/5056 Init + JoinGame(226) + RaiseEvent(253) in-world
Matchmaking is the VRChat HTTPS API. Photon is the instance transport. On the game UDP connection the only LoadBalancing ops observed are JoinGame (226) and an unsolicited AuthenticateOnce (231) response; every subsequent VRChat message is RaiseEvent (253) / Event.
Plaintext UDP datagram (12 bytes, big-endian):
[0:2] peerID u16 0xFFFF until VerifyConnect; server→client is 0x0000
[2] flags u8 0x00 plaintext, 0x01 encrypted, 0xCC CRC
[3] cmdCount u8
[4:8] sentTime u32 ms
[8:12] challenge u32 connection id, constant for the session
Encrypted datagram: same 7-byte cleartext header (peerID ‖ 0x01 ‖ challenge), then nonce(12) ‖ ciphertext ‖ tag(16). Cipher is AES-256-GCM. Key is the 32-byte encryption_secret from NameServer AuthenticateOnce param 192. Nonce is a 96-bit counter (client counter is process-lifetime). AAD is header(7) ‖ nonce(12).
ENet command header (12 bytes, BE): [type][channel][flags][reserved=0x04][size u32][reliableSeq u32]. Reliable send is 0x06, unreliable 0x07, fragment 0x08, ping 0x05, Ack2 0x11. Unreliable workhorse in-world is 0x0B (EgSendUnreliableProcessed).
Photon message inside a command payload: F3 <msgType> <body>.
| msgType | name |
|---|---|
| 1 | InitResponse |
| 2 | OperationRequest |
| 3 | OperationResponse |
| 4 | Event |
| 5 | DisconnectReason |
Op/event parameter tables are u8 count then count × (u8 key, typed value).
- HTTPS login (
GET /auth/user, 2FA if needed) →authcookie. - HTTPS reservation for a location
wrld_<uuid>:<instance>~region(xx)→ join token. - WSS NameServer AuthenticateOnce →
encryption_secret+ PhotonToken. - UDP Connect / VerifyConnect, then plaintext HTTP-over-UDP
POST /?init=&app=<appId>&clientversion=5.1.17.0&protocol=GpBinaryV18&sid=31&whose body is a GpBinaryV18 String of base64(PhotonToken). PhotonToken =0x02+ N×16 opaque bytes. The server encrypts from its first reply. - Master JoinGame
{255: location, 215: 1}→{230: "ip:port", 221: token}. - Game Init (same token) + JoinGame with player/game properties and the travel JWE.
- Post-join RaiseEvents: 34 (rate-limit table request), 67 (attestation, every ~5 s), then 33, 202, 3, 23, 25. Event 202 instantiates the local
VRCPlayer(view idsactor × 100000 + {1,2,3}). Event 67 is the in-world anti-cheat attestation heartbeat: RSAES-PKCS1-v1.5 encryption ofSHA256(ts ‖ SEP ‖ nonceHex ‖ SEP ‖ context ‖ SEP ‖ PREFIX)under a per-build RSA public key. Construction and input provenance: docs/attestation.md
Photon AppId: bf0942f7-9935-4192-b359-f092fa85bef1.
| File | Contents |
|---|---|
| docs/architecture.md | Assemblies, hops, what lives where |
| docs/transport.md | UDP framing, command types, fragments, channels |
| docs/encryption.md | AES-256-GCM construction, key provenance |
| docs/gp18.md | GpBinaryV18 type table and message framing |
| docs/nameserver.md | WSS NameServer, Sec-WebSocket-Protocol: GpBinaryV18 |
| docs/handshake.md | Connect, Init, AuthenticateOnce |
| docs/world-join.md | The four JoinGame(226) messages |
| docs/post-join.md | Raise sequence after JoinGame |
| docs/events.md | Event-code catalog |
| docs/attestation.md | Event 67 construction (per-build secrets omitted) |
| docs/http-api.md | Login / reservation HTTPS |
| docs/metadata.md | BeeByte global-metadata.dat decryption (PC + Android) |
Per-event payload schemas (outer GP18 + inner blobs) live in docs/events/.
Python 3.9+, stdlib only.
# decode a Photon message, an init POST, or a raw GpBinaryV18 value
python3 scripts/decode_gp_binary.py --selftest
python3 scripts/decode_gp_binary.py --hex 'f302fd01f40322'
python3 scripts/decode_gp_binary.py capture.bin
# decrypt BeeByte-obfuscated IL2CPP metadata (writes a full decrypted copy)
python3 scripts/decrypt_metadata_android.py global-metadata.dat out.bin
python3 scripts/decrypt_metadata_pc.py global-metadata.dat out.binencode(decode(b)) == b holds for canonical GpBinaryV18 input, including captured Init and JoinGame messages.
| Item | Value |
|---|---|
| Photon AppId | bf0942f7-9935-4192-b359-f092fa85bef1 |
| Client version (init query) | 5.1.17.0 |
| Serialization | GpBinaryV18, sid=31 |
| WSS subprotocol | GpBinaryV18 |
| Datagram GCM | AES-256, AAD = 7-byte header ‖ 12-byte nonce, tag 16 B trailing |
| Fragment payload (plaintext) | MTU − 48 |
| PUN view-id stride | 100 000 per actor (stock PUN is 1 000) |
| RaiseEvent param order | 252 or 246, then 244, then 245 |