# Photon wire findings — Rec Room PC client 20250718.01 (captured 2026-09-15) Source: pktmon capture of a real login + dorm join against Photon Cloud (`scratchpad/cap/photon.pcapng`, decoders `enet.py` / `gp16.py` / `session.py` / `summary.py`). Everything below is **observed**, not recalled. ## Transport (ENet-style UDP, all big-endian) Packet header, 12 bytes: `peerId:int16, crc:byte(0), commandCount:byte, sentTime:int32, challenge:int32`. Client → server header peerId = the id assigned in VerifyConnect (e.g. 0xF1AA); server → client header peerId = 0. Command header, 12 bytes: `type:byte, channel:byte, flags:byte (1 reliable, 2 unsequenced), reserved:byte(4), length:int32 (incl. header), reliableSeq:int32`. | type | name | payload | |---|---|---| | 1 | ACK | `receivedReliableSeq:int32, receivedSentTime:int32` (flags 0, seq 0, channel = acked cmd's channel) | | 2 | CONNECT | 32 bytes: `00 00 04 b0 00 00 80 00 00 00 00 02 00 00 00 00 00 00 00 00 00 00 13 88 00 00 00 02 00 00 00 02` (mtu 1200, 2 channels) — channel 255, reliable, seq 1, header peerId -1 | | 3 | VERIFYCONNECT | 32 bytes: assigned `peerId:int16` then `04 b0 00 00 80 00 00 00 00 02` + zeros — channel 255, seq 1 | | 4 | DISCONNECT | none (flags 2) | | 5 | PING | none, reliable, channel 255 — both directions, ~1/s | | 6 | SEND_RELIABLE | message | | 7 | SEND_UNRELIABLE | `unreliableSeq:int32` + message | | 8 | SEND_FRAGMENT | `startSeq:int32, fragCount:int32, fragNumber:int32, totalLength:int32, fragOffset:int32` + data; reassembled = one reliable message with seq = startSeq | | 12 | SERVERTIME | client → server only, reliable ch 255, no payload. Server just ACKs it; the client reads the server clock from the ack packet's header `sentTime`. Server never sends type 12. | Game/master traffic uses channel 0 only. Reliable seq numbers are per channel (255 has its own). ## Message layer (inside commands 6/7/8) `0xF3, msgType` then body. msgType `& 0x80` = encrypted (AES of the body). Types seen: 0 Init (client), 1 InitResponse (server: `f3 01 00`), 2 OperationRequest, 3 OperationResponse, 4 Event, 6 InternalOperationRequest, 7 InternalOperationResponse. Init body: `protoMajor:byte protoMinor:byte 1e 41 06 19 00` + app name padded to 32 bytes. - PUN (Realtime) connections send **`01 06` = GpBinary 1.6** with app name = the Realtime app id (or `NameServer` on the name server). - Photon Voice/Chat connections send `01 08` (1.8). Not needed for CampusNet (voice is Tachyon). ### GpBinary 1.6 encoding (what PUN uses) OperationRequest: `opCode:byte, paramCount:int16, (key:byte, value)*` OperationResponse: `opCode:byte, returnCode:int16, debugMessage:value(usually '*'), paramCount:int16, params` Event: `eventCode:byte, paramCount:int16, params` Values: type byte then data — `*` null, `b` byte, `o` bool, `k` int16, `i` int32, `l` int64, `f` float, `d` double, `s` string (int16 len, utf8), `x` byte[] (int32 len), `n` int[] (int32 count), `a` string[] (int16 count), `h` Hashtable (int16 count, typed key + typed value), `D` Dictionary (keyType:byte, valType:byte (0 = per-entry typed), int16 count), `z` object[] (int16 count, typed), `y` typed array (int16 count, elemType:byte, elements untyped; elemType `c` = custom: `code:byte` then per element `len:int16 bytes`), `c` custom (`code:byte, len:int16, bytes`). ## Encryption InternalOperation 0 (InitEncryption): client sends param 1 = 96-byte DH public key; server answers param 1 = 96-byte key (768-bit Oakley group). Client then sends the NameServer Authenticate **encrypted** (msgType 0x82). On master/game the Authenticate REQUEST is plaintext (only the secret token inside), Photon's responses to Authenticate/Join are encrypted — the client decrypts only when the flag is set, so a plaintext response is fine from our own server. ## Operations / events observed (codes = standard Photon LoadBalancing) NameServer: encrypted Authenticate (230) — response gives master address (230) + secret (221). Master: Init(app id) → InitEncryption → Authenticate 230 {221 secret} → resp rc 0 (encrypted) → event 226 AppStats {227 MasterPeerCount, 228 PeerCount, 229 GameCount} (unreliable) → JoinGame 226 {255 GameId, 215 JoinMode=1 CreateIfNotExists} → resp (encrypted: game address + secret) → Disconnect. Game: Init → InitEncryption → Authenticate 230 {221} → resp rc 0 (plaintext, no params) → JoinGame 226 {255 GameId, 215 1, 249 PlayerProps{PlatformId, HardwareType, DeviceClass, S, prev_activity, rm_isopinprogress, 255 NickName}, 250 Broadcast, 248 GameProps{253 IsOpen, 254 IsVisible, 250 PropsListedInLobby string[], 'BUG':1}, 241, 232, 239 (bools), 191 RoomFlags=59} → resp (encrypted) + event 255 Join (encrypted). Then: SetProperties 252 {251 props, 250 broadcast, 254 actorNr (player props), 231 expected (CAS)} → resp 252 rc 0 + event 253 {251 props, 253 targetActor (0 = room), 254 sender}; RaiseEvent 253 {244 code, 245 data, 247 cache, 246 receiverGroup} — cache 4/5/6 used (AddToRoomCache, AddToRoomCacheGlobal, RemoveFromRoomCache); ReceiverGroup 1 = All (server echoes to sender), absent = Others. Event to receivers: {245 data, 254 sender actor}. Game event codes used: 22,23,24,26,27,29,31,32,33,34,36. Room props carry the anti-cheat hash checks (`32.hshchk_gr`, `P1.hshchk_gr`, `39.tmhash`) — client-side values the server only stores. Custom auth token = the Campus server's `photonAuthToken` (HS256 JWT, sub = account id, aud = photon room id).