using System.Buffers.Binary; using System.Collections; using System.Text; namespace CampusNet.Protocol; /// /// A value the client sent as a custom type ('c'): PUN registers Vector3/Quaternion/Vector2/Player /// etc. under one-letter codes. The server never interprets them - it stores and re-emits the bytes. /// public sealed record CustomValue(byte Code, byte[] Data); /// An array the client sent with an explicit element type ('y'): re-emitted the same way. public sealed record TypedArray(byte ElemType, object?[] Items, byte CustomCode = 0); /// A Dictionary sent with typed keys/values ('D'); key/value type 0 = per-entry typed. public sealed class TypedDictionary { public byte KeyType, ValueType; public readonly List> Entries = new(); } /// /// Photon GpBinary 1.6 - the wire format PUN uses on every Realtime connection (verified from a live /// capture, see docs/photon-wire-findings.md). Big-endian throughout. Hashtables are the workhorse: /// keys are typed values (bytes for system props, strings for custom props). /// public static class GpBinary16 { public const byte Magic = 0xF3; // Message types (the byte after 0xF3; bit 0x80 = encrypted body). public const byte MsgInit = 0, MsgInitResponse = 1, MsgOperationRequest = 2, MsgOperationResponse = 3, MsgEvent = 4, MsgInternalOperationRequest = 6, MsgInternalOperationResponse = 7, MsgMessage = 8, MsgRawMessage = 9; public const byte EncryptedFlag = 0x80; // ---------------------------------------------------------------- reading public ref struct Reader { private readonly ReadOnlySpan _b; public int Pos; public Reader(ReadOnlySpan b, int pos = 0) { _b = b; Pos = pos; } public int Remaining => _b.Length - Pos; public byte U8() => _b[Pos++]; public short I16() { var v = BinaryPrimitives.ReadInt16BigEndian(_b.Slice(Pos)); Pos += 2; return v; } public int I32() { var v = BinaryPrimitives.ReadInt32BigEndian(_b.Slice(Pos)); Pos += 4; return v; } public long I64() { var v = BinaryPrimitives.ReadInt64BigEndian(_b.Slice(Pos)); Pos += 8; return v; } public float F32() { var v = BinaryPrimitives.ReadSingleBigEndian(_b.Slice(Pos)); Pos += 4; return v; } public double F64() { var v = BinaryPrimitives.ReadDoubleBigEndian(_b.Slice(Pos)); Pos += 8; return v; } public byte[] Bytes(int n) { var v = _b.Slice(Pos, n).ToArray(); Pos += n; return v; } public string Str() { int n = I16(); var s = Encoding.UTF8.GetString(_b.Slice(Pos, n)); Pos += n; return s; } public object? Value() => Value(U8()); public object? Value(byte t) { switch ((char)t) { case '*': return null; case 'b': return U8(); case 'o': return U8() != 0; case 'k': return I16(); case 'i': return I32(); case 'l': return I64(); case 'f': return F32(); case 'd': return F64(); case 's': return Str(); case 'x': { int n = I32(); return Bytes(n); } case 'n': { int n = I32(); var a = new int[n]; for (int i = 0; i < n; i++) a[i] = I32(); return a; } case 'a': { int n = I16(); var a = new string[n]; for (int i = 0; i < n; i++) a[i] = Str(); return a; } case 'h': { int n = I16(); var h = new Hashtable(n); for (int i = 0; i < n; i++) { var k = Value(); var v = Value(); if (k != null) h[k] = v; } return h; } case 'D': { var d = new TypedDictionary { KeyType = U8(), ValueType = U8() }; int n = I16(); for (int i = 0; i < n; i++) { var k = d.KeyType == 0 ? Value() : Value(d.KeyType); var v = d.ValueType == 0 ? Value() : Value(d.ValueType); d.Entries.Add(new(k, v)); } return d; } case 'z': { int n = I16(); var a = new object?[n]; for (int i = 0; i < n; i++) a[i] = Value(); return a; } case 'y': { int n = I16(); byte et = U8(); if ((char)et == 'c') { byte code = U8(); var items = new object?[n]; for (int i = 0; i < n; i++) { int len = I16(); items[i] = new CustomValue(code, Bytes(len)); } return new TypedArray(et, items, code); } var arr = new object?[n]; for (int i = 0; i < n; i++) arr[i] = Value(et); return new TypedArray(et, arr); } case 'c': { byte code = U8(); int len = I16(); return new CustomValue(code, Bytes(len)); } default: throw new InvalidDataException($"GpBinary16: unknown type 0x{t:x2} at {Pos - 1}"); } } public Dictionary Params() { int n = I16(); var d = new Dictionary(n); for (int i = 0; i < n; i++) { byte k = U8(); d[k] = Value(); } return d; } } public sealed class OperationRequest { public byte Code; public Dictionary Params = new(); public int PayloadBytes; /* wire size of the whole message, for budgets */ } public sealed class OperationResponse { public byte Code; public short ReturnCode; public string? Debug; public Dictionary Params = new(); } public sealed class EventData { public byte Code; public Dictionary Params = new(); } public static OperationRequest ReadOperationRequest(ReadOnlySpan body) { var r = new Reader(body); var op = new OperationRequest { Code = r.U8() }; op.Params = r.Params(); return op; } public static OperationResponse ReadOperationResponse(ReadOnlySpan body) { var r = new Reader(body); var op = new OperationResponse { Code = r.U8(), ReturnCode = r.I16() }; op.Debug = r.Value() as string; op.Params = r.Params(); return op; } public static EventData ReadEvent(ReadOnlySpan body) { var r = new Reader(body); var ev = new EventData { Code = r.U8() }; ev.Params = r.Params(); return ev; } // ---------------------------------------------------------------- writing public sealed class Writer { private byte[] _b = new byte[512]; public int Length; public Writer(int cap = 512) { _b = new byte[cap]; } private void Ensure(int n) { if (Length + n > _b.Length) Array.Resize(ref _b, Math.Max(_b.Length * 2, Length + n)); } public void U8(byte v) { Ensure(1); _b[Length++] = v; } public void I16(short v) { Ensure(2); BinaryPrimitives.WriteInt16BigEndian(_b.AsSpan(Length), v); Length += 2; } public void I32(int v) { Ensure(4); BinaryPrimitives.WriteInt32BigEndian(_b.AsSpan(Length), v); Length += 4; } public void I64(long v) { Ensure(8); BinaryPrimitives.WriteInt64BigEndian(_b.AsSpan(Length), v); Length += 8; } public void F32(float v) { Ensure(4); BinaryPrimitives.WriteSingleBigEndian(_b.AsSpan(Length), v); Length += 4; } public void F64(double v) { Ensure(8); BinaryPrimitives.WriteDoubleBigEndian(_b.AsSpan(Length), v); Length += 8; } public void Raw(ReadOnlySpan s) { Ensure(s.Length); s.CopyTo(_b.AsSpan(Length)); Length += s.Length; } public void Str(string s) { var n = Encoding.UTF8.GetByteCount(s); I16((short)n); Ensure(n); Encoding.UTF8.GetBytes(s, _b.AsSpan(Length)); Length += n; } public byte[] ToArray() => _b.AsSpan(0, Length).ToArray(); public ReadOnlySpan Span => _b.AsSpan(0, Length); /// Write a typed value (type byte + data). public void Value(object? v) { switch (v) { case null: U8((byte)'*'); break; case byte b: U8((byte)'b'); U8(b); break; case bool o: U8((byte)'o'); U8(o ? (byte)1 : (byte)0); break; case short k: U8((byte)'k'); I16(k); break; case int i: U8((byte)'i'); I32(i); break; case long l: U8((byte)'l'); I64(l); break; case float f: U8((byte)'f'); F32(f); break; case double d: U8((byte)'d'); F64(d); break; case string s: U8((byte)'s'); Str(s); break; case byte[] x: U8((byte)'x'); I32(x.Length); Raw(x); break; case int[] n: U8((byte)'n'); I32(n.Length); foreach (var e in n) I32(e); break; case string[] a: U8((byte)'a'); I16((short)a.Length); foreach (var e in a) Str(e); break; case Hashtable h: U8((byte)'h'); I16((short)h.Count); foreach (DictionaryEntry e in h) { Value(e.Key); Value(e.Value); } break; case TypedDictionary td: U8((byte)'D'); U8(td.KeyType); U8(td.ValueType); I16((short)td.Entries.Count); foreach (var e in td.Entries) { if (td.KeyType == 0) Value(e.Key); else ValueBody(e.Key, td.KeyType); if (td.ValueType == 0) Value(e.Value); else ValueBody(e.Value, td.ValueType); } break; case TypedArray ta: U8((byte)'y'); I16((short)ta.Items.Length); U8(ta.ElemType); if ((char)ta.ElemType == 'c') { U8(ta.CustomCode); foreach (var e in ta.Items) { var c = (CustomValue)e!; I16((short)c.Data.Length); Raw(c.Data); } } else foreach (var e in ta.Items) ValueBody(e, ta.ElemType); break; case object?[] z: U8((byte)'z'); I16((short)z.Length); foreach (var e in z) Value(e); break; case CustomValue c: U8((byte)'c'); U8(c.Code); I16((short)c.Data.Length); Raw(c.Data); break; case Dictionary dbo: // convenience: byte-keyed dictionary as Hashtable U8((byte)'h'); I16((short)dbo.Count); foreach (var e in dbo) { Value(e.Key); Value(e.Value); } break; case Dictionary dio: // actorNr -> props as Hashtable with int keys U8((byte)'h'); I16((short)dio.Count); foreach (var e in dio) { Value(e.Key); Value(e.Value); } break; case Dictionary doo: U8((byte)'h'); I16((short)doo.Count); foreach (var e in doo) { Value(e.Key); Value(e.Value); } break; default: throw new NotSupportedException($"GpBinary16: cannot serialize {v.GetType()}"); } } /// Write only the data of a value whose type byte was already written (typed arrays/dictionaries). private void ValueBody(object? v, byte t) { // Re-use Value() and drop the leading type byte it wrote. int start = Length; Value(v); if (_b[start] != t) { // Element type mismatch (e.g. int in a 'k' array) - fix up the type byte position. throw new InvalidDataException($"GpBinary16: element type {(char)_b[start]} does not match array type {(char)t}"); } Buffer.BlockCopy(_b, start + 1, _b, start, Length - start - 1); Length--; } public void Params(IReadOnlyDictionary p) { I16((short)p.Count); foreach (var e in p) { U8(e.Key); Value(e.Value); } } } public static byte[] OperationResponseMessage(byte opCode, short returnCode, string? debug, IReadOnlyDictionary p, bool internalOp = false) { var w = new Writer(); w.U8(Magic); w.U8(internalOp ? MsgInternalOperationResponse : MsgOperationResponse); w.U8(opCode); w.I16(returnCode); w.Value(debug); w.Params(p); return w.ToArray(); } public static byte[] EventMessage(byte eventCode, IReadOnlyDictionary p) { var w = new Writer(); w.U8(Magic); w.U8(MsgEvent); w.U8(eventCode); w.Params(p); return w.ToArray(); } /// Body (after the 0xF3 + type header) of an operation response - what gets encrypted when a reply must be. public static byte[] OperationResponseBody(byte opCode, short returnCode, string? debug, IReadOnlyDictionary p) { var w = new Writer(); w.U8(opCode); w.I16(returnCode); w.Value(debug); w.Params(p); return w.ToArray(); } public static byte[] InitResponse() => new byte[] { Magic, MsgInitResponse, 0 }; public static string Describe(object? v, int depth = 0) { switch (v) { case null: return "null"; case string s: return $"\"{s}\""; case byte[] x: return $"byte[{x.Length}]"; case int[] n: return "[" + string.Join(",", n) + "]"; case string[] a: return "[" + string.Join(",", a.Select(s => "\"" + s + "\"")) + "]"; case Hashtable h: if (depth > 2) return $"{{…{h.Count}}}"; return "{" + string.Join(", ", h.Cast().Select(e => Describe(e.Key, depth + 1) + ":" + Describe(e.Value, depth + 1))) + "}"; case TypedDictionary d: return $"D[{d.Entries.Count}]"; case TypedArray ta: return $"{(char)ta.ElemType}[{ta.Items.Length}]"; case object?[] z: return "(" + string.Join(", ", z.Select(e => Describe(e, depth + 1))) + ")"; case CustomValue c: return $"custom'{(char)c.Code}'[{c.Data.Length}]"; default: return v.ToString() ?? "?"; } } }