Files
Campus-25/server/CampusNet/docs/photon-wire-findings.md
T
2026-10-14 06:59:51 +02:00

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 (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).