5.4 KiB
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 (orNameServeron 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).