the files

This commit is contained in:
niko
2026-10-14 06:59:51 +02:00
parent f6175893fc
commit 2040419525
1855 changed files with 6735668 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>CampusNet</RootNamespace>
<AssemblyName>campusnet</AssemblyName>
<InvariantGlobalization>true</InvariantGlobalization>
<ServerGarbageCollection>false</ServerGarbageCollection>
</PropertyGroup>
</Project>
+125
View File
@@ -0,0 +1,125 @@
using System.Collections.Concurrent;
using System.Net;
using CampusNet.Services;
namespace CampusNet.Net;
/// <summary>
/// Abuse limits for the whole stack. Every number has an environment override (CAMPUSNET_*) so a
/// limit can be tuned on the box without a rebuild. The numbers are sized so a real client never
/// meets them: the Rec Room client's steady state is a few dozen events a second and well under
/// 100 KB/s, and a burst of RPCs on a scene change is hundreds, not thousands.
///
/// 2026-09-17: the op/byte budgets were raised ~10x. The maker pen's Clone and Split send one
/// RPC per object in a single burst (a big selection is thousands of ops in a second), and the
/// 200 ops/s bucket was silently dropping the tail of that burst ("over budget (op 253)") -
/// the other clients never saw the clones and the pen looked broken. A budget must sit far
/// above anything a builder can do; these only stop a flood, not a build session.
///
/// Transport (per peer / per IP, PhotonUdpServer)
/// MaxPeersPerIp simultaneous connections from one address (a household behind NAT)
/// ConnectsPerIpPer10s new CONNECTs per address per 10 s (a bot spinning up peers)
/// InBytesPerSec / InBytesBurst inbound bytes per peer, token bucket - excess packets are
/// dropped before decryption/decoding; a peer over it for
/// FloodKickAfterSec straight is dropped and its IP cooled for IpBanSec
/// MaxFragmentTotal largest reassembled message (the fragment header's "total" is
/// client-supplied - unchecked it is an allocation of any size)
/// MaxFragmentSets concurrent reassemblies per peer
/// MaxPendingReliable out-of-order reliable commands buffered per channel
/// Game (per connection, GameService)
/// OpsPerSec / OpsBurst operations (RaiseEvent, SetProperties, ...) per connection
/// EventBytesPerSec / EventBytesBurst RaiseEvent payload bytes per connection
/// MaxEventBytes one event's payload
/// MaxRoomCache / MaxActorCache cached events per room / per actor
/// OpKickAfter dropped operations in a row before the connection is closed and
/// its IP cooled for IpBanSec
/// </summary>
public static class Guard
{
public static readonly int MaxPeersPerIp = Env("CAMPUSNET_MAX_PEERS_PER_IP", 16);
public static readonly int ConnectsPerIpPer10s = Env("CAMPUSNET_CONNECTS_PER_IP_10S", 30);
public static readonly int InBytesPerSec = Env("CAMPUSNET_IN_BYTES_PER_SEC", 2048 * 1024);
public static readonly int InBytesBurst = Env("CAMPUSNET_IN_BYTES_BURST", 8192 * 1024);
public static readonly int FloodKickAfterSec = Env("CAMPUSNET_FLOOD_KICK_AFTER_SEC", 5);
public static readonly int MaxFragmentTotal = Env("CAMPUSNET_MAX_FRAGMENT_TOTAL", 8 * 1024 * 1024);
public static readonly int MaxFragmentSets = Env("CAMPUSNET_MAX_FRAGMENT_SETS", 16);
public static readonly int MaxPendingReliable = Env("CAMPUSNET_MAX_PENDING_RELIABLE", 4096);
public static readonly int IpBanSec = Env("CAMPUSNET_IP_BAN_SEC", 300);
public static readonly int OpsPerSec = Env("CAMPUSNET_OPS_PER_SEC", 2000);
public static readonly int OpsBurst = Env("CAMPUSNET_OPS_BURST", 10000);
public static readonly int EventBytesPerSec = Env("CAMPUSNET_EVENT_BYTES_PER_SEC", 2048 * 1024);
public static readonly int EventBytesBurst = Env("CAMPUSNET_EVENT_BYTES_BURST", 8192 * 1024);
public static readonly int MaxEventBytes = Env("CAMPUSNET_MAX_EVENT_BYTES", 4096 * 1024);
public static readonly int MaxRoomCache = Env("CAMPUSNET_MAX_ROOM_CACHE", 100000);
public static readonly int MaxActorCache = Env("CAMPUSNET_MAX_ACTOR_CACHE", 8192);
public static readonly int OpKickAfter = Env("CAMPUSNET_OP_KICK_AFTER", 1500);
public static readonly int OpFloodBanSec = Env("CAMPUSNET_OP_FLOOD_BAN_SEC", 60);
private static int Env(string name, int dflt) => int.TryParse(Environment.GetEnvironmentVariable(name), out int v) ? v : dflt;
// ---------------------------------------------------------------- ip cooldown
private static readonly ConcurrentDictionary<string, long> _banned = new();
private static readonly ConcurrentDictionary<string, (int count, long windowStart)> _connects = new();
public static string IpOf(EndPoint ep) => ep is IPEndPoint ip ? ip.Address.ToString() : ep.ToString() ?? "";
public static void BanIp(string ip, string why, int? seconds = null)
{
long until = DateTimeOffset.UtcNow.ToUnixTimeSeconds() + (seconds ?? IpBanSec);
_banned.AddOrUpdate(ip, until, (_, cur) => Math.Max(cur, until));
Log.Warn($"guard: ip {ip} cooled for {(seconds ?? IpBanSec)}s - {why}");
}
public static bool IsBanned(string ip)
{
if (!_banned.TryGetValue(ip, out long until)) return false;
if (until > DateTimeOffset.UtcNow.ToUnixTimeSeconds()) return true;
_banned.TryRemove(ip, out _);
return false;
}
/// <summary>Count a CONNECT from this IP; false when it is over ConnectsPerIpPer10s.</summary>
public static bool AllowConnect(string ip)
{
long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
var e = _connects.AddOrUpdate(ip, _ => (1, now), (_, cur) => now - cur.windowStart >= 10 ? (1, now) : (cur.count + 1, cur.windowStart));
if (_connects.Count > 10000) foreach (var kv in _connects) if (now - kv.Value.windowStart > 60) _connects.TryRemove(kv.Key, out _);
return e.count <= ConnectsPerIpPer10s;
}
}
/// <summary>Token bucket: <c>rate</c> tokens/s refilled continuously, at most <c>burst</c> banked.</summary>
public sealed class TokenBucket
{
private readonly double _rate, _burst;
private double _tokens;
private long _last;
public long Dropped; // total refused
public long DroppedSince; // refusals in the current violation run
public long ViolatingSinceMs; // when the current run started, 0 = clean
private long _lastDropMs = long.MinValue;
private const int RunGapMs = 2000; // a run ends after this long without a refusal
public TokenBucket(int ratePerSec, int burst) { _rate = ratePerSec; _burst = burst; _tokens = burst; _last = long.MinValue; }
/// <param name="nowMs">any monotonic millisecond clock - the bucket adopts the first one it sees</param>
public bool Take(double n, long nowMs)
{
if (_last == long.MinValue) _last = nowMs;
double dt = (nowMs - _last) / 1000.0;
if (dt > 0) { _tokens = Math.Min(_burst, _tokens + dt * _rate); _last = nowMs; }
if (_tokens >= n)
{
_tokens -= n;
if (ViolatingSinceMs != 0 && nowMs - _lastDropMs > RunGapMs) { DroppedSince = 0; ViolatingSinceMs = 0; }
return true;
}
// A run of refusals: a client that stays over budget keeps the run alive even though
// the trickle of refilled tokens lets the odd operation through in between.
if (ViolatingSinceMs == 0 || nowMs - _lastDropMs > RunGapMs) { DroppedSince = 0; ViolatingSinceMs = nowMs; }
Dropped++; DroppedSince++; _lastDropMs = nowMs;
return false;
}
}
@@ -0,0 +1,667 @@
using System.Buffers.Binary;
using System.Collections.Concurrent;
using System.Net;
using System.Net.Sockets;
namespace CampusNet.Net;
/// <summary>
/// Photon's ENet-style reliable UDP (wire details verified from a capture, docs/photon-wire-findings.md):
/// 12-byte packet header + N commands, per-channel reliable sequencing with acks and retransmission,
/// unreliable sequencing, fragmentation above the MTU, ping/keep-alive, server-time via ack timestamp.
/// One instance per listening port; the service on top receives whole messages per peer.
/// </summary>
public sealed class PhotonUdpServer
{
public const int Mtu = 1200;
private const int PacketHeader = 12, CommandHeader = 12;
private const byte CmdAck = 1, CmdConnect = 2, CmdVerifyConnect = 3, CmdDisconnect = 4, CmdPing = 5,
CmdReliable = 6, CmdUnreliable = 7, CmdFragment = 8, CmdServerTime = 12;
private const byte FlagReliable = 1, FlagUnsequenced = 2;
private const byte ChannelSystem = 255;
// How long a peer may go SILENT before it is dropped. It was 12 s, and a reliable command
// left unacked for ~11 s dropped it too - so a player who took their headset off, tabbed out or
// froze for a moment was thrown out of the room (2026-09-27: 434 "timeout" and 159 "unacked,
// last heard >5 s" game drops in a day). Photon itself keeps a paused client alive for a minute
// (PUN KeepAliveInBackground 60 s). Room owners / co-owners and staff get AfkExemptTimeoutMs
// (Peer.TimeoutMs, set once the Campus server confirms who they are - see AfkExempt).
public static readonly int PeerTimeoutMs = EnvInt("CAMPUSNET_PEER_TIMEOUT_MS", 60000);
public static readonly int AfkExemptTimeoutMs = EnvInt("CAMPUSNET_EXEMPT_TIMEOUT_MS", 600000);
private const int PingIntervalMs = 1000, MaxRetries = 8;
// An unanswered reliable command is NOT a reason to drop a peer that is still talking to us unless that peer has acked NOTHING at all for
// this long. It used to be: 8 retries (~14 s) with the peer heard in the last 3 s. Two things made that throw healthy players out
// (2026-10-13: 10 "unacked" drops in 6 h, "pending" counts of 20 / 93 / 102 / 376 / 523 with "last heard" a few ms ago):
// - a peer that went quiet for a while (a hitch, a tab-out, Wi-Fi) piled up unacked commands - one keep-alive ping a second - and every
// one of them had long used up its retries, so the instant its first packet arrived (and it was "heard" again, but had not yet acked
// the backlog) the tick dropped it; the client only noticed ~15 s later (ClientTimeout), as a black screen and a reload into the dorm;
// - a single keep-alive ping whose ack was lost kept the peer on the hook while every other command was being acked.
// Now a quiet peer gets a fresh retry budget when it speaks again, an unanswered ping is simply forgotten while the peer acks other
// things, and the peer is dropped for this only when it keeps talking but has acked nothing for UnackedDropMs.
public static readonly int UnackedDropMs = EnvInt("CAMPUSNET_UNACKED_DROP_MS", 30000);
private const int ResumeGapMs = 3000, MaxPendingPings = 20;
private static int EnvInt(string name, int fallback) =>
int.TryParse(Environment.GetEnvironmentVariable(name), out var v) && v > 0 ? v : fallback;
public readonly int Port;
public readonly string Name;
private readonly Socket _sock;
private readonly ConcurrentDictionary<EndPoint, Peer> _peers = new();
private readonly System.Diagnostics.Stopwatch _clock = System.Diagnostics.Stopwatch.StartNew();
private readonly Random _rng = new();
private int ServerTime => (int)_clock.ElapsedMilliseconds;
public event Action<Peer, byte[], bool>? OnMessage; // a complete message (0xF3 ...), reliable?
public event Action<Peer>? OnDisconnect;
public event Action<Peer>? OnConnect;
public PhotonUdpServer(string name, int port)
{
Name = name; Port = port;
_sock = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
try { _sock.IOControl(-1744830452, new byte[] { 0 }, null); } catch { } // SIO_UDP_CONNRESET off (Windows)
_sock.Bind(new IPEndPoint(IPAddress.Any, port));
}
public int PeerCount => _peers.Count;
public IEnumerable<Peer> Peers => _peers.Values;
// ------------------------------------------------------------ peer
public sealed class Peer
{
public readonly EndPoint Remote;
public readonly short PeerId;
public int Challenge;
public bool Connected;
public long LastRecv, LastPing, LastSend, LastAck; // LastAck: when the peer last acknowledged anything of ours
public int TimeoutMs = PeerTimeoutMs; // silence allowed before the peer is dropped
public int Rtt = 200;
public object? Session; // the service's per-connection state
internal readonly Dictionary<byte, ChannelState> Channels = new();
internal readonly List<PendingAck> AckQueue = new();
internal readonly List<OutCommand> OutQueue = new();
internal readonly Dictionary<long, OutCommand> UnackedBySeq = new();
internal readonly Dictionary<long, Fragmenter> Fragments = new();
internal int FragmentEvictions;
internal readonly HashSet<byte> UnknownTypesLogged = new();
internal int UnreliableOutSeq;
internal bool Disconnecting;
internal readonly TokenBucket InBytes = new(Guard.InBytesPerSec, Guard.InBytesBurst);
// Health ledger, so a disconnect can be explained afterwards: running totals, and every 5 s a snapshot of what changed
// (packets in / out, acks received from the peer, reliable commands received from it, how many of OUR reliable commands are
// still unacked and how old the oldest is). Printed by DropPeer when the last 30 s look wrong.
internal long PktsIn, PktsOut, AcksIn, RelIn;
internal long SnapAt, SnapPktsIn, SnapPktsOut, SnapAcksIn, SnapRelIn, ConnectedAt;
internal readonly Queue<(long At, long In, long Out, long Acks, long Rel, int Unacked, long OldestMs)> Snaps = new();
public readonly string Ip;
public Peer(EndPoint remote, short id) { Remote = remote; PeerId = id; Ip = Guard.IpOf(remote); }
public override string ToString() => $"{Remote}#{PeerId}";
}
internal sealed class ChannelState
{
public int ReliableOut; // last reliable seq sent
public int ReliableInExpected = 1;
public int UnreliableIn;
// Out-of-order reliable messages, with how many reliable seqs each occupies (1, or a
// fragmented message's fragment count - it must advance the stream by that much).
public readonly SortedDictionary<int, (byte[] Message, int Span)> Pending = new();
}
internal struct PendingAck { public byte Channel; public int Seq; public int SentTime; }
internal sealed class OutCommand
{
public byte Type, Channel, Flags; public int Seq; public byte[] Payload = Array.Empty<byte>();
public long FirstSent, LastSent; public int Retries; public bool Reliable => (Flags & FlagReliable) != 0;
public int Length => CommandHeader + Payload.Length;
}
internal sealed class Fragmenter { public int Count, Total; public byte Channel; public int Start; public DateTime Created = DateTime.UtcNow; public readonly SortedDictionary<int, byte[]> Parts = new(); }
// ------------------------------------------------------------ callbacks outside the peer lock
//
// The game loop FROZE for good on 2026-10-03 (every join sat at "18%"; the game socket's receive queue
// filled and nothing ran again). Cause: a lock-order cycle. The tick thread dropped a silent peer while
// holding lock(peer), and the disconnect handler then wanted room.Sync; at the same moment the receive
// thread held room.Sync (relaying a message to that very peer, which needs lock(peer)). Handlers ran
// INSIDE lock(peer) everywhere, so peer -> room and room -> peer both existed.
// Now the message and disconnect handlers are queued while a peer lock is held and run once it is
// released, so a peer lock never waits on a room lock: the only order left is room -> peer.
[ThreadStatic] private static List<Action>? _post;
private static void Post(Action a) { if (_post != null) _post.Add(a); else a(); }
/// <summary>Run the queued callbacks (the thread's peer lock must already be released). Returns how many ran.</summary>
private static int RunPosted()
{
var l = _post; _post = null;
if (l == null) return 0;
foreach (var a in l) a();
return l.Count;
}
// ------------------------------------------------------------ run loop
public async Task RunAsync(CancellationToken ct)
{
Console.WriteLine($"[{Name}] listening on udp/{Port}");
var buf = new byte[65536];
var tick = Task.Run(() => TickLoop(ct), ct);
while (!ct.IsCancellationRequested)
{
SocketReceiveFromResult r;
try { r = await _sock.ReceiveFromAsync(buf, SocketFlags.None, new IPEndPoint(IPAddress.Any, 0), ct); }
catch (OperationCanceledException) { break; }
catch (SocketException) { continue; }
catch (Exception ex) { Console.WriteLine($"[{Name}] recv error: {ex.Message}"); continue; }
try { HandlePacket(r.RemoteEndPoint, buf.AsSpan(0, r.ReceivedBytes)); }
catch (Exception ex) { Console.WriteLine($"[{Name}] packet from {r.RemoteEndPoint} failed: {ex}"); }
}
await tick;
}
private async Task TickLoop(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
try { await Task.Delay(20, ct); } catch { break; }
long now = _clock.ElapsedMilliseconds;
foreach (var p in _peers.Values)
{
_post = new List<Action>();
try
{
lock (p)
{
if (p.Connected && now - p.SnapAt >= 5000) TakeSnapshot(p, now);
if (now - p.LastRecv > p.TimeoutMs) { DropPeer(p, $"timeout ({p.TimeoutMs / 1000} s silent)"); continue; }
if (p.Connected && now - p.LastPing > PingIntervalMs)
{
p.LastPing = now;
// Not while twenty are already waiting: a quiet peer used to collect one a second (523 were seen), all
// retransmitted the moment it came back. The retransmissions of the waiting ones keep the line warm.
if (p.UnackedBySeq.Values.Count(u => u.Type == CmdPing) < MaxPendingPings)
Enqueue(p, new OutCommand { Type = CmdPing, Channel = ChannelSystem, Flags = FlagReliable });
}
// Retransmit unacked reliable commands.
foreach (var c in p.UnackedBySeq.Values.ToList())
{
int rto = Math.Clamp(p.Rtt * 2 + 50, 200, 2000) << Math.Min(c.Retries, 3);
if (now - c.LastSent < rto) continue;
// Out of retries only counts when the client is still TALKING to us and acking NOTHING (see UnackedDropMs).
// A silent client (paused, AFK, a hiccup) is given its whole timeout instead - it acks everything once it
// is back (ResumeAfterSilence also resets the retry count then).
bool usedUp = c.Retries++ >= MaxRetries;
// A keep-alive ping nobody answered while the peer acks everything else: it carries nothing, forget it.
if (usedUp && c.Type == CmdPing && now - p.LastAck < 5000) { p.UnackedBySeq.Remove(Key(c.Channel, c.Seq)); continue; }
if (usedUp && now - p.LastRecv < 3000 && now - p.LastAck >= UnackedDropMs)
{
// Say WHICH command went unanswered and whether the client was still
// talking to us. ~1 in 10 disconnects are "unacked", spread across all
// players: a recent last-heard means the client is alive but refuses
// this one command (a CampusNet bug to fix); an old one means the
// network path dropped out.
string what = $"type {c.Type} ch {c.Channel} seq {c.Seq} {c.Payload.Length}B";
if (c.Payload.Length > 3 && (c.Type == CmdReliable || c.Type == CmdFragment))
{
var b = c.Payload; int o = c.Type == CmdFragment ? 20 : 0;
if (b.Length > o + 2 && b[o] == 0xF3) what += $" msg {b[o + 1] & 0x7F} code {b[o + 2]}";
if (c.Type == CmdFragment && b.Length >= 20) what += $" frag {BinaryPrimitives.ReadInt32BigEndian(b.AsSpan(8))}/{BinaryPrimitives.ReadInt32BigEndian(b.AsSpan(4))} total {BinaryPrimitives.ReadInt32BigEndian(b.AsSpan(12))}B";
}
DropPeer(p, $"unacked command ({what}; pending {p.UnackedBySeq.Count}, last heard {now - p.LastRecv} ms ago, last ack {now - p.LastAck} ms ago, rtt {p.Rtt} ms)");
break;
}
p.OutQueue.Add(c);
}
Flush(p);
}
}
catch (Exception ex) { Console.WriteLine($"[{Name}] tick {p}: {ex.Message}"); }
finally { try { RunPosted(); } catch (Exception ex) { Console.WriteLine($"[{Name}] tick {p} callbacks: {ex.Message}"); } }
}
}
}
private static (long At, long In, long Out, long Acks, long Rel, int Unacked, long OldestMs) Snap(Peer p, long now)
{
long oldest = 0;
foreach (var c in p.UnackedBySeq.Values) if (c.FirstSent > 0) oldest = Math.Max(oldest, now - c.FirstSent);
var s = (now, p.PktsIn - p.SnapPktsIn, p.PktsOut - p.SnapPktsOut, p.AcksIn - p.SnapAcksIn, p.RelIn - p.SnapRelIn, p.UnackedBySeq.Count, oldest);
p.SnapPktsIn = p.PktsIn; p.SnapPktsOut = p.PktsOut; p.SnapAcksIn = p.AcksIn; p.SnapRelIn = p.RelIn; p.SnapAt = now;
return s;
}
private static void TakeSnapshot(Peer p, long now)
{
p.Snaps.Enqueue(Snap(p, now));
while (p.Snaps.Count > 6) p.Snaps.Dequeue();
}
/// <summary>When a peer goes and the last 30 s look wrong - one of our commands waited 3 s or more for its ack, or the peer sent no acks while we
/// were sending - say what each 5 s slice looked like, so "the server stopped hearing the client" can be told from "the client stopped hearing
/// the server" afterwards. Quiet for normal leaves.</summary>
private void ExplainDrop(Peer p, string why)
{
try
{
if (!p.Connected || p.ConnectedAt == 0) return;
long now = _clock.ElapsedMilliseconds;
var list = p.Snaps.ToList();
if (now - p.SnapAt > 500) list.Add(Snap(p, now)); // the slice still in progress
bool odd = list.Any(s => s.OldestMs >= 3000 || (s.Out > 3 && s.Acks == 0 && s.At - p.ConnectedAt > 6000));
if (!odd) return;
var sb = new System.Text.StringBuilder();
foreach (var s in list) sb.Append($" [{(s.At - now) / 1000.0:F0}s: in {s.In} out {s.Out} acks {s.Acks} rel {s.Rel} unacked {s.Unacked}/{s.OldestMs}ms]");
Console.WriteLine($"[{Name}] {p} ({why}) last slices (in/out = packets, acks = from the peer, rel = reliable commands from the peer, unacked = ours waiting / oldest age):{sb}");
}
catch { }
}
/// <summary>The peer spoke again after at least ResumeGapMs of silence: whatever piled up unacked while it was away starts over with a
/// full retry budget and the peer gets UnackedDropMs of grace to acknowledge the backlog. Called with the peer's lock held.</summary>
private void ResumeAfterSilence(Peer peer, long now)
{
int n = 0;
foreach (var c in peer.UnackedBySeq.Values) { c.Retries = 0; n++; }
peer.LastAck = now;
if (n > 0) Console.WriteLine($"[{Name}] {peer} is back after {(now - peer.LastRecv) / 1000.0:F1} s of silence - {n} unacked command(s) get a fresh retry budget");
}
// ------------------------------------------------------------ receive
// Photon's region ping (Photon Realtime PhotonPing / PingMono): a bare 13-byte datagram - twelve
// 0x7D bytes and a ping id - that the server echoes back unchanged. The client pings the region's
// master before authenticating and retries 5 x 800 ms when nothing answers. We dropped it (only a
// CONNECT may create a peer), so EVERY login sat through that ~4-5 s timeout on the name server
// (key exchange -> authenticate took 4.4-5.4 s in the logs). Echoing it removes the wait.
private static bool IsPhotonPing(ReadOnlySpan<byte> pkt)
{
if (pkt.Length < 9 || pkt.Length > 16) return false;
for (int i = 0; i < pkt.Length - 1; i++) if (pkt[i] != 0x7D) return false;
return true;
}
private void HandlePacket(EndPoint from, ReadOnlySpan<byte> pkt)
{
if (IsPhotonPing(pkt))
{
if (!Guard.IsBanned(Guard.IpOf(from)))
try { _sock.SendTo(pkt.ToArray(), SocketFlags.None, from); } catch { }
return;
}
if (pkt.Length < PacketHeader) return;
short peerId = BinaryPrimitives.ReadInt16BigEndian(pkt);
int count = pkt[3];
int sentTime = BinaryPrimitives.ReadInt32BigEndian(pkt.Slice(4));
int challenge = BinaryPrimitives.ReadInt32BigEndian(pkt.Slice(8));
if (!_peers.TryGetValue(from, out var peer))
{
// Only a CONNECT may create a peer.
if (pkt.Length < PacketHeader + CommandHeader || pkt[PacketHeader] != CmdConnect) return;
string ip = Guard.IpOf(from);
if (Guard.IsBanned(ip)) return;
if (!Guard.AllowConnect(ip)) { Guard.BanIp(ip, $"more than {Guard.ConnectsPerIpPer10s} connects in 10s on {Name}"); return; }
if (_peers.Values.Count(p => p.Ip == ip) >= Guard.MaxPeersPerIp)
{
Console.WriteLine($"[{Name}] connect from {from} refused: {Guard.MaxPeersPerIp} peers already from {ip}");
return;
}
short id;
do { id = (short)_rng.Next(1, short.MaxValue); } while (_peers.Values.Any(p => p.PeerId == id));
// LastRecv starts NOW, not 0: the peer is visible to the tick thread (_peers) before the lock below
// stamps it, and with LastRecv = 0 the tick saw "silent for the whole uptime" and dropped the new
// connection at once ("gone (timeout (60 s silent))" in the same second as its connect) - about one
// connect in twenty, which left the client at "Failed to connect to region" / stuck at 18%.
peer = new Peer(from, id) { Challenge = challenge, LastRecv = _clock.ElapsedMilliseconds, LastAck = _clock.ElapsedMilliseconds };
_peers[from] = peer;
Console.WriteLine($"[{Name}] connect from {from} -> peer {id}");
}
_post = new List<Action>();
int posted = 0;
try
{
lock (peer)
{
long nowMs = _clock.ElapsedMilliseconds;
if (peer.Connected && nowMs - peer.LastRecv >= ResumeGapMs) ResumeAfterSilence(peer, nowMs);
peer.LastRecv = nowMs;
peer.PktsIn++;
// Inbound flood: over the byte budget the packet is dropped before any decode work;
// a peer that stays over it is closed and its address cooled.
if (!peer.InBytes.Take(pkt.Length, nowMs))
{
if (peer.InBytes.DroppedSince == 1) Console.WriteLine($"[{Name}] {peer} over {Guard.InBytesPerSec} B/s inbound - dropping packets");
if (nowMs - peer.InBytes.ViolatingSinceMs > Guard.FloodKickAfterSec * 1000L)
{
Guard.BanIp(peer.Ip, $"inbound flood on {Name} ({peer.InBytes.Dropped} packets dropped)");
DropPeer(peer, "inbound flood");
}
return;
}
int pos = PacketHeader;
for (int i = 0; i < count && pos + CommandHeader <= pkt.Length; i++)
{
byte type = pkt[pos], channel = pkt[pos + 1], flags = pkt[pos + 2];
int len = BinaryPrimitives.ReadInt32BigEndian(pkt.Slice(pos + 4));
int seq = BinaryPrimitives.ReadInt32BigEndian(pkt.Slice(pos + 8));
if (len < CommandHeader || pos + len > pkt.Length) break;
var body = pkt.Slice(pos + CommandHeader, len - CommandHeader);
HandleCommand(peer, type, channel, flags, seq, sentTime, body);
pos += len;
}
Flush(peer);
}
}
finally { posted = RunPosted(); }
if (posted > 0) lock (peer) Flush(peer); // what the handlers just queued (the 20 ms tick would send it too)
}
private void HandleCommand(Peer peer, byte type, byte channel, byte flags, int seq, int sentTime, ReadOnlySpan<byte> body)
{
if ((flags & FlagReliable) != 0 && type != CmdAck)
{
peer.AckQueue.Add(new PendingAck { Channel = channel, Seq = seq, SentTime = sentTime });
peer.RelIn++;
}
else if (type == CmdAck) peer.AcksIn++;
switch (type)
{
case CmdAck:
{
if (body.Length < 8) return;
int ackedSeq = BinaryPrimitives.ReadInt32BigEndian(body);
int ackedSent = BinaryPrimitives.ReadInt32BigEndian(body.Slice(4));
peer.LastAck = _clock.ElapsedMilliseconds;
if (peer.UnackedBySeq.Remove(Key(channel, ackedSeq), out var cmd))
{
int rtt = ServerTime - ackedSent;
if (rtt >= 0 && rtt < 5000) peer.Rtt = (peer.Rtt * 7 + rtt) / 8;
}
return;
}
case CmdConnect:
{
// Answer VerifyConnect with the peer id we assigned; the rest echoes the client's connect block.
var payload = new byte[32];
if (body.Length >= 32) body.Slice(0, 32).CopyTo(payload);
BinaryPrimitives.WriteInt16BigEndian(payload, peer.PeerId);
for (int i = 12; i < 32; i++) payload[i] = 0;
if (!peer.Connected)
{
peer.Connected = true;
peer.ConnectedAt = peer.SnapAt = _clock.ElapsedMilliseconds;
peer.LastPing = _clock.ElapsedMilliseconds;
Enqueue(peer, new OutCommand { Type = CmdVerifyConnect, Channel = ChannelSystem, Flags = FlagReliable, Payload = payload });
OnConnect?.Invoke(peer);
}
return;
}
case CmdDisconnect:
DropPeer(peer, "client disconnect");
return;
case CmdPing:
case CmdServerTime:
return; // the ack (with our header sentTime) is the whole answer
case CmdReliable:
DeliverReliable(peer, channel, seq, body.ToArray());
return;
case CmdUnreliable:
{
if (body.Length < 4) return;
int useq = BinaryPrimitives.ReadInt32BigEndian(body);
var ch = Channel(peer, channel);
if (useq <= ch.UnreliableIn) return; // late/duplicate
ch.UnreliableIn = useq;
Dispatch(peer, body.Slice(4).ToArray(), false);
return;
}
case CmdFragment:
{
if (body.Length < 20) return;
int start = BinaryPrimitives.ReadInt32BigEndian(body);
int fcount = BinaryPrimitives.ReadInt32BigEndian(body.Slice(4));
int total = BinaryPrimitives.ReadInt32BigEndian(body.Slice(12));
int offset = BinaryPrimitives.ReadInt32BigEndian(body.Slice(16));
long key = Key(channel, start);
// The fragment header is client-supplied: bound the reassembly buffer and the number
// of half-built messages before either becomes an allocation the client controls.
if (total <= 0 || total > Guard.MaxFragmentTotal || fcount <= 0 || fcount > Guard.MaxFragmentTotal / 64 || offset < 0 || offset >= total)
{
Guard.BanIp(peer.Ip, $"bad fragment header on {Name} (total {total}, count {fcount}, offset {offset})");
DropPeer(peer, "bad fragment");
return;
}
// A resent fragment of a message that was already rebuilt and delivered (our ack
// was lost or slow) - it is acked above; it must NOT open a new reassembly. That
// used to start a half-built message that could never complete; big messages
// (screen-share frames, ~50 fragments each) leaked them until the peer was dropped
// for "too many partial fragments".
var fch = Channel(peer, channel);
if (start < fch.ReliableInExpected) return;
if (!peer.Fragments.TryGetValue(key, out var f))
{
if (peer.Fragments.Count >= Guard.MaxFragmentSets)
{
// Bounded, but never a disconnect: first discard sets the stream has already
// moved past or that have been stuck for 30 s, then the oldest one.
var now = DateTime.UtcNow;
foreach (var kv in peer.Fragments.Where(kv => kv.Value.Start < Channel(peer, kv.Value.Channel).ReliableInExpected
|| (now - kv.Value.Created).TotalSeconds > 30).ToList())
peer.Fragments.Remove(kv.Key);
if (peer.Fragments.Count >= Guard.MaxFragmentSets)
{
var oldest = peer.Fragments.OrderBy(kv => kv.Value.Created).First();
peer.Fragments.Remove(oldest.Key);
if (peer.FragmentEvictions++ == 0)
Console.WriteLine($"[{Name}] {peer} has {Guard.MaxFragmentSets} partial messages in flight - discarding the oldest (logged once per peer)");
}
}
peer.Fragments[key] = f = new Fragmenter { Count = fcount, Total = total, Channel = channel, Start = start };
}
f.Parts[offset] = body.Slice(20).ToArray();
if (f.Parts.Count < f.Count) return;
peer.Fragments.Remove(key);
var whole = new byte[f.Total]; int p = 0;
foreach (var part in f.Parts.Values) { Buffer.BlockCopy(part, 0, whole, p, Math.Min(part.Length, whole.Length - p)); p += part.Length; }
// Fragments occupy reliable seqs start..start+count-1; deliver in order at `start`.
DeliverReliable(peer, channel, start, whole, fcount);
return;
}
default:
// Unknown command type: say so once per (peer, type) - a missing delivery mode
// (unsequenced variants are 11/14/15/16) shows up here instead of vanishing.
if (peer.UnknownTypesLogged.Add(type))
Console.WriteLine($"[{Name}] {peer} sent command type {type} (channel {channel}, flags {flags}, {body.Length} bytes) - not handled, dropped");
return;
}
}
private static long Key(byte channel, int seq) => ((long)channel << 32) | (uint)seq;
private ChannelState Channel(Peer p, byte c) { if (!p.Channels.TryGetValue(c, out var s)) p.Channels[c] = s = new ChannelState(); return s; }
private void DeliverReliable(Peer peer, byte channel, int seq, byte[] message, int span = 1)
{
var ch = Channel(peer, channel);
if (seq < ch.ReliableInExpected) return; // duplicate (already acked above)
if (seq > ch.ReliableInExpected)
{
if (ch.Pending.Count >= Guard.MaxPendingReliable) { DropPeer(peer, "reliable window overflow"); return; }
ch.Pending[seq] = (message, span); return;
}
ch.ReliableInExpected = seq + span;
Dispatch(peer, message, true);
while (ch.Pending.Count > 0)
{
var first = ch.Pending.First();
if (first.Key != ch.ReliableInExpected) break;
ch.Pending.Remove(first.Key);
// Advance by the message's SPAN: a parked fragmented message occupies fcount seqs.
// Advancing by 1 left the stream waiting forever for a seq that was part of it, so
// every later reliable message from that player stalled (a frozen screen share).
ch.ReliableInExpected += first.Value.Span;
Dispatch(peer, first.Value.Message, true);
}
}
private void Dispatch(Peer peer, byte[] message, bool reliable)
{
if (message.Length == 0) return;
Post(() =>
{
try { OnMessage?.Invoke(peer, message, reliable); }
catch (Exception ex) { Console.WriteLine($"[{Name}] handler error for {peer}: {ex}"); }
});
}
// ------------------------------------------------------------ send
public void SendReliable(Peer peer, byte[] message, byte channel = 0)
{
lock (peer)
{
if (peer.Disconnecting) return;
int maxBody = Mtu - PacketHeader - CommandHeader;
if (message.Length <= maxBody)
{
Enqueue(peer, new OutCommand { Type = CmdReliable, Channel = channel, Flags = FlagReliable, Payload = message });
return;
}
int chunk = maxBody - 20;
int count = (message.Length + chunk - 1) / chunk;
var ch = Channel(peer, channel);
int start = ch.ReliableOut + 1;
for (int i = 0; i < count; i++)
{
int off = i * chunk, len = Math.Min(chunk, message.Length - off);
var payload = new byte[20 + len];
BinaryPrimitives.WriteInt32BigEndian(payload, start);
BinaryPrimitives.WriteInt32BigEndian(payload.AsSpan(4), count);
BinaryPrimitives.WriteInt32BigEndian(payload.AsSpan(8), i);
BinaryPrimitives.WriteInt32BigEndian(payload.AsSpan(12), message.Length);
BinaryPrimitives.WriteInt32BigEndian(payload.AsSpan(16), off);
Buffer.BlockCopy(message, off, payload, 20, len);
Enqueue(peer, new OutCommand { Type = CmdFragment, Channel = channel, Flags = FlagReliable, Payload = payload });
}
}
}
public void SendUnreliable(Peer peer, byte[] message, byte channel = 0)
{
lock (peer)
{
if (peer.Disconnecting) return;
if (message.Length > Mtu - PacketHeader - CommandHeader - 4) { SendReliable(peer, message, channel); return; }
var payload = new byte[4 + message.Length];
BinaryPrimitives.WriteInt32BigEndian(payload, ++peer.UnreliableOutSeq);
Buffer.BlockCopy(message, 0, payload, 4, message.Length);
Enqueue(peer, new OutCommand { Type = CmdUnreliable, Channel = channel, Flags = 0, Payload = payload });
}
}
public void Disconnect(Peer peer, string why)
{
bool own = _post == null;
if (own) _post = new List<Action>();
try
{
lock (peer)
{
if (peer.Disconnecting) return;
Enqueue(peer, new OutCommand { Type = CmdDisconnect, Channel = ChannelSystem, Flags = FlagUnsequenced });
Flush(peer);
DropPeer(peer, why);
}
}
finally { if (own) RunPosted(); }
}
private void Enqueue(Peer peer, OutCommand c)
{
var ch = Channel(peer, c.Channel);
if (c.Reliable)
{
c.Seq = ++ch.ReliableOut;
peer.UnackedBySeq[Key(c.Channel, c.Seq)] = c;
}
else
{
// ENet sequences unreliable commands AGAINST the channel's reliable stream: the header's
// sequence field must carry the last reliable seq sent on the channel. The receiver
// holds an unreliable command until it has that reliable command, and DISCARDS one whose
// reliable seq is older than what it already has. Stamping 0 here meant every unreliable
// event the server relayed - all the avatar movement - was thrown away as stale the
// moment the client had received its first reliable message (the join response).
c.Seq = ch.ReliableOut;
}
peer.OutQueue.Add(c);
}
/// <summary>Pack queued acks + commands into MTU-sized packets and send them.</summary>
private void Flush(Peer peer)
{
if (peer.AckQueue.Count == 0 && peer.OutQueue.Count == 0) return;
long now = _clock.ElapsedMilliseconds;
var pkt = new byte[Mtu];
int pos = PacketHeader, n = 0;
void Send()
{
if (n == 0) return;
BinaryPrimitives.WriteInt16BigEndian(pkt, 0);
pkt[2] = 0; pkt[3] = (byte)n;
BinaryPrimitives.WriteInt32BigEndian(pkt.AsSpan(4), ServerTime);
BinaryPrimitives.WriteInt32BigEndian(pkt.AsSpan(8), peer.Challenge);
try { _sock.SendTo(pkt, 0, pos, SocketFlags.None, peer.Remote); peer.PktsOut++; } catch (Exception ex) { Console.WriteLine($"[{Name}] send to {peer} failed: {ex.Message}"); }
pos = PacketHeader; n = 0;
}
void Put(byte type, byte channel, byte flags, int seq, ReadOnlySpan<byte> payload)
{
int len = CommandHeader + payload.Length;
if (pos + len > Mtu) Send();
pkt[pos] = type; pkt[pos + 1] = channel; pkt[pos + 2] = flags; pkt[pos + 3] = 4;
BinaryPrimitives.WriteInt32BigEndian(pkt.AsSpan(pos + 4), len);
BinaryPrimitives.WriteInt32BigEndian(pkt.AsSpan(pos + 8), seq);
payload.CopyTo(pkt.AsSpan(pos + CommandHeader));
pos += len; n++;
if (n == 255) Send();
}
foreach (var a in peer.AckQueue)
{
Span<byte> ap = stackalloc byte[8];
BinaryPrimitives.WriteInt32BigEndian(ap, a.Seq);
BinaryPrimitives.WriteInt32BigEndian(ap.Slice(4), a.SentTime);
Put(CmdAck, a.Channel, 0, 0, ap);
}
peer.AckQueue.Clear();
foreach (var c in peer.OutQueue)
{
if (c.Reliable) { if (c.FirstSent == 0) c.FirstSent = now; c.LastSent = now; }
Put(c.Type, c.Channel, c.Flags, c.Seq, c.Payload);
}
peer.OutQueue.Clear();
Send();
peer.LastSend = now;
}
/// <summary>Close a peer for abuse and cool its address (Guard.IpBanSec).</summary>
public void Kick(Peer peer, string why, int? banSeconds = null)
{
Guard.BanIp(peer.Ip, $"{why} on {Name}", banSeconds);
Disconnect(peer, why);
}
private void DropPeer(Peer peer, string why)
{
if (peer.Disconnecting) return;
peer.Disconnecting = true;
_peers.TryRemove(peer.Remote, out _);
Console.WriteLine($"[{Name}] {peer} gone ({why})");
ExplainDrop(peer, why);
Post(() => { try { OnDisconnect?.Invoke(peer); } catch (Exception ex) { Console.WriteLine($"[{Name}] disconnect handler: {ex}"); } });
}
}
+42
View File
@@ -0,0 +1,42 @@
using CampusNet.Net;
using CampusNet.Services;
// CampusNet - a Photon-protocol (LoadBalancing over ENet-style UDP, GpBinary 1.6) game server for the
// Rec Room client. Runs the name server, master and game roles in one process on the standard ports.
//
// CAMPUSNET_PUBLIC_HOST the IP/host clients reach this machine on (what the name server hands out)
// CAMPUSNET_NS_PORT / CAMPUSNET_MASTER_PORT / CAMPUSNET_GAME_PORT default 5058 / 5055 / 5056
// CAMPUSNET_JWT_KEY the Campus server's HS256 key (validates the photonAuthToken)
// CAMPUSNET_ALLOW_UNSIGNED=1 accept a plain UserId when no token is presented (testing only)
// CAMPUSNET_DEBUG=1 log every operation
string publicHost = Environment.GetEnvironmentVariable("CAMPUSNET_PUBLIC_HOST") ?? "127.0.0.1";
int nsPort = int.Parse(Environment.GetEnvironmentVariable("CAMPUSNET_NS_PORT") ?? "5058");
int masterPort = int.Parse(Environment.GetEnvironmentVariable("CAMPUSNET_MASTER_PORT") ?? "5055");
int gamePort = int.Parse(Environment.GetEnvironmentVariable("CAMPUSNET_GAME_PORT") ?? "5056");
var cts = new CancellationTokenSource();
Console.CancelKeyPress += (_, e) => { e.Cancel = true; cts.Cancel(); };
AppDomain.CurrentDomain.ProcessExit += (_, _) => cts.Cancel();
var ns = new PhotonUdpServer("name", nsPort);
var master = new PhotonUdpServer("master", masterPort);
var game = new PhotonUdpServer("game", gamePort);
_ = new LoadBalancingApp(Role.NameServer, ns, publicHost, masterPort, gamePort);
_ = new LoadBalancingApp(Role.Master, master, publicHost, masterPort, gamePort);
_ = new LoadBalancingApp(Role.Game, game, publicHost, masterPort, gamePort);
Log.Info($"CampusNet starting: public host {publicHost}, name/{nsPort} master/{masterPort} game/{gamePort}, DH generator {CampusNet.Protocol.PhotonCrypto.Generator}");
JoinBlocks.Start(); // kicked players stay out of the instance they were kicked from
var sweeper = Task.Run(async () =>
{
while (!cts.IsCancellationRequested)
{
try { await Task.Delay(1000, cts.Token); } catch { break; }
try { GameService.Sweep(); } catch (Exception ex) { Log.Warn($"sweep: {ex.Message}"); }
}
});
await Task.WhenAll(ns.RunAsync(cts.Token), master.RunAsync(cts.Token), game.RunAsync(cts.Token), sweeper);
@@ -0,0 +1,289 @@
using System.Buffers.Binary;
using System.Collections;
using System.Text;
namespace CampusNet.Protocol;
/// <summary>
/// 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.
/// </summary>
public sealed record CustomValue(byte Code, byte[] Data);
/// <summary>An array the client sent with an explicit element type ('y'): re-emitted the same way.</summary>
public sealed record TypedArray(byte ElemType, object?[] Items, byte CustomCode = 0);
/// <summary>A Dictionary sent with typed keys/values ('D'); key/value type 0 = per-entry typed.</summary>
public sealed class TypedDictionary
{
public byte KeyType, ValueType;
public readonly List<KeyValuePair<object?, object?>> Entries = new();
}
/// <summary>
/// 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).
/// </summary>
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<byte> _b;
public int Pos;
public Reader(ReadOnlySpan<byte> 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<byte, object?> Params()
{
int n = I16(); var d = new Dictionary<byte, object?>(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<byte, object?> Params = new(); public int PayloadBytes; /* wire size of the whole message, for budgets */ public Dictionary<byte, byte[]>? Raw; /* 1.8 only: each param's encoded value, type byte included - relayed verbatim (Photon Chat messages) */ }
public sealed class OperationResponse { public byte Code; public short ReturnCode; public string? Debug; public Dictionary<byte, object?> Params = new(); }
public sealed class EventData { public byte Code; public Dictionary<byte, object?> Params = new(); }
public static OperationRequest ReadOperationRequest(ReadOnlySpan<byte> body)
{
var r = new Reader(body);
var op = new OperationRequest { Code = r.U8() };
op.Params = r.Params();
return op;
}
public static OperationResponse ReadOperationResponse(ReadOnlySpan<byte> 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<byte> 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<byte> 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<byte> Span => _b.AsSpan(0, Length);
/// <summary>Write a typed value (type byte + data).</summary>
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<byte, object?> 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<int, object?> 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<object, object?> 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()}");
}
}
/// <summary>Write only the data of a value whose type byte was already written (typed arrays/dictionaries).</summary>
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<byte, object?> 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<byte, object?> 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<byte, object?> p)
{
var w = new Writer();
w.U8(Magic); w.U8(MsgEvent);
w.U8(eventCode); w.Params(p);
return w.ToArray();
}
/// <summary>Body (after the 0xF3 + type header) of an operation response - what gets encrypted when a reply must be.</summary>
public static byte[] OperationResponseBody(byte opCode, short returnCode, string? debug, IReadOnlyDictionary<byte, object?> 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<DictionaryEntry>().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() ?? "?";
}
}
}
@@ -0,0 +1,211 @@
using System.Buffers.Binary;
using System.Collections;
using System.Text;
namespace CampusNet.Protocol;
/// <summary>
/// Photon GpBinary 1.8 - what the Photon Voice / newer client libraries speak (the PUN connections
/// use 1.6). Compact: counts are varints, ints are zig-zag varints, type codes are numeric.
/// Enough of it to run the name-server hop for the voice client and to hand it addresses.
/// </summary>
public static class GpBinary18
{
// GpType
private const byte TUnknown = 0, TBoolean = 2, TByte = 3, TShort = 4, TFloat = 5, TDouble = 6, TString = 7, TNull = 8,
TCompressedInt = 9, TCompressedLong = 10, TInt1 = 11, TInt1_ = 12, TInt2 = 13, TInt2_ = 14, TL1 = 15, TL1_ = 16,
TL2 = 17, TL2_ = 18, TCustom = 19, TDictionary = 20, THashtable = 21, TObjectArray = 23, TOperationRequest = 24,
TOperationResponse = 25, TEventData = 26, TBooleanFalse = 27, TBooleanTrue = 28, TShortZero = 29, TIntZero = 30,
TLongZero = 31, TFloatZero = 32, TDoubleZero = 33, TByteZero = 34, TArray = 64, TByteArray = 67, TCustomSlim = 128;
public ref struct Reader
{
private readonly ReadOnlySpan<byte> _b; public int Pos;
public Reader(ReadOnlySpan<byte> b, int pos = 0) { _b = b; Pos = pos; }
public byte U8() => _b[Pos++];
public uint VarUInt() { uint v = 0; int shift = 0; while (true) { byte b = U8(); v |= (uint)(b & 0x7F) << shift; if ((b & 0x80) == 0) return v; shift += 7; } }
public ulong VarULong() { ulong v = 0; int shift = 0; while (true) { byte b = U8(); v |= (ulong)(b & 0x7F) << shift; if ((b & 0x80) == 0) return v; shift += 7; } }
public int ZigInt() { uint u = VarUInt(); return (int)(u >> 1) ^ -(int)(u & 1); }
public long ZigLong() { ulong u = VarULong(); return (long)(u >> 1) ^ -(long)(u & 1); }
public short I16() { var v = BinaryPrimitives.ReadInt16BigEndian(_b.Slice(Pos)); Pos += 2; 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 = (int)VarUInt(); var s = Encoding.UTF8.GetString(_b.Slice(Pos, n)); Pos += n; return s; }
public object? Value() => Value(U8());
public object? Value(byte t)
{
switch (t)
{
case TNull: return null;
case TBoolean: return U8() != 0;
case TBooleanFalse: return false;
case TBooleanTrue: return true;
case TByte: return U8();
case TByteZero: return (byte)0;
case TShort: return I16();
case TShortZero: return (short)0;
case TFloat: return F32();
case TFloatZero: return 0f;
case TDouble: return F64();
case TDoubleZero: return 0d;
case TString: return Str();
case TCompressedInt: return ZigInt();
case TIntZero: return 0;
case TInt1: return (int)U8();
case TInt1_: return -(int)U8();
case TInt2: return (int)(ushort)I16();
case TInt2_: return -(int)(ushort)I16();
case TCompressedLong: return ZigLong();
case TLongZero: return 0L;
case TL1: return (long)U8();
case TL1_: return -(long)U8();
case TL2: return (long)(ushort)I16();
case TL2_: return -(long)(ushort)I16();
case TByteArray: { int n = (int)VarUInt(); return Bytes(n); }
case THashtable:
{
int n = (int)VarUInt(); 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 TDictionary:
{
byte kt = U8(), vt = U8(); int n = (int)VarUInt();
var d = new TypedDictionary { KeyType = kt, ValueType = vt };
for (int i = 0; i < n; i++) { var k = kt == TUnknown ? Value() : Value(kt); var v = vt == TUnknown ? Value() : Value(vt); d.Entries.Add(new(k, v)); }
return d;
}
case TObjectArray: { int n = (int)VarUInt(); var a = new object?[n]; for (int i = 0; i < n; i++) a[i] = Value(); return a; }
case TCustom: { byte code = U8(); int n = (int)VarUInt(); return new CustomValue(code, Bytes(n)); }
default:
if ((t & TCustomSlim) != 0) { byte code = (byte)(t & 0x7F); int n = (int)VarUInt(); return new CustomValue(code, Bytes(n)); }
if ((t & TArray) != 0)
{
int n = (int)VarUInt(); byte et = (byte)(t & ~TArray);
if (et == TBoolean)
{
// Packed: 8 per byte, least significant bit first.
var a = new bool[n]; int i = 0;
while (i < n) { byte bits = U8(); for (int k = 0; k < 8 && i < n; k++, i++) a[i] = (bits & (1 << k)) != 0; }
return a;
}
if (et == TString) { var a = new string[n]; for (int i = 0; i < n; i++) a[i] = Str(); return a; }
if (et == TCompressedInt) { var a = new int[n]; for (int i = 0; i < n; i++) a[i] = ZigInt(); return a; }
var arr = new object?[n];
for (int i = 0; i < n; i++) arr[i] = Value(et);
return new TypedArray(et, arr);
}
throw new InvalidDataException($"GpBinary18: unknown type {t} at {Pos - 1}");
}
}
public Dictionary<byte, object?> Params() => Params(null);
public Dictionary<byte, object?> Params(Dictionary<byte, byte[]>? raw)
{
int n = U8(); var d = new Dictionary<byte, object?>(n);
for (int i = 0; i < n; i++)
{
byte k = U8(); int start = Pos;
d[k] = Value();
if (raw != null) raw[k] = _b.Slice(start, Pos - start).ToArray();
}
return d;
}
}
public static GpBinary16.OperationRequest ReadOperationRequest(ReadOnlySpan<byte> body)
{
var r = new Reader(body);
var op = new GpBinary16.OperationRequest { Code = r.U8(), Raw = new() };
op.Params = r.Params(op.Raw);
return op;
}
public sealed class Writer
{
private byte[] _b = new byte[256]; public int Length;
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 Raw(ReadOnlySpan<byte> s) { Ensure(s.Length); s.CopyTo(_b.AsSpan(Length)); Length += s.Length; }
public void VarUInt(uint v) { while (v >= 0x80) { U8((byte)(v | 0x80)); v >>= 7; } U8((byte)v); }
public void I16(short v) { Ensure(2); BinaryPrimitives.WriteInt16BigEndian(_b.AsSpan(Length), v); Length += 2; }
public void Str(string s) { var n = Encoding.UTF8.GetByteCount(s); VarUInt((uint)n); Ensure(n); Encoding.UTF8.GetBytes(s, _b.AsSpan(Length)); Length += n; }
public byte[] ToArray() => _b.AsSpan(0, Length).ToArray();
public void Value(object? v)
{
switch (v)
{
case null: U8(TNull); break;
case bool o: U8(o ? TBooleanTrue : TBooleanFalse); break;
case byte b: if (b == 0) U8(TByteZero); else { U8(TByte); U8(b); } break;
case short k: if (k == 0) U8(TShortZero); else { U8(TShort); I16(k); } break;
case int i:
if (i == 0) U8(TIntZero);
else { U8(TCompressedInt); uint z = (uint)((i << 1) ^ (i >> 31)); VarUInt(z); }
break;
case long l:
if (l == 0) U8(TLongZero);
else { U8(TCompressedLong); ulong z = (ulong)((l << 1) ^ (l >> 63)); while (z >= 0x80) { U8((byte)(z | 0x80)); z >>= 7; } U8((byte)z); }
break;
case float f: if (f == 0) U8(TFloatZero); else { U8(TFloat); Ensure(4); BinaryPrimitives.WriteSingleBigEndian(_b.AsSpan(Length), f); Length += 4; } break;
case double d: if (d == 0) U8(TDoubleZero); else { U8(TDouble); Ensure(8); BinaryPrimitives.WriteDoubleBigEndian(_b.AsSpan(Length), d); Length += 8; } break;
case string s: U8(TString); Str(s); break;
case byte[] x: U8(TByteArray); VarUInt((uint)x.Length); Raw(x); break;
case string[] a: U8(TArray | TString); VarUInt((uint)a.Length); foreach (var e in a) Str(e); break;
case int[] n: U8(TArray | TCompressedInt); VarUInt((uint)n.Length); foreach (var e in n) { uint z = (uint)((e << 1) ^ (e >> 31)); VarUInt(z); } break;
case Hashtable h: U8(THashtable); VarUInt((uint)h.Count); foreach (DictionaryEntry e in h) { Value(e.Key); Value(e.Value); } break;
case object?[] z: U8(TObjectArray); VarUInt((uint)z.Length); foreach (var e in z) Value(e); break;
case bool[] ba:
{
U8(TArray | TBoolean); VarUInt((uint)ba.Length);
for (int i = 0; i < ba.Length; i += 8)
{
byte bits = 0;
for (int k = 0; k < 8 && i + k < ba.Length; k++) if (ba[i + k]) bits |= (byte)(1 << k);
U8(bits);
}
break;
}
case RawValue rv: Raw(rv.Bytes); break;
case CustomValue c: U8(TCustom); U8(c.Code); VarUInt((uint)c.Data.Length); Raw(c.Data); break;
default: throw new NotSupportedException($"GpBinary18: cannot serialize {v.GetType()}");
}
}
public void Params(IReadOnlyDictionary<byte, object?> p)
{
U8((byte)p.Count);
foreach (var e in p) { U8(e.Key); Value(e.Value); }
}
}
public static byte[] OperationResponseMessage(byte opCode, short returnCode, string? debug, IReadOnlyDictionary<byte, object?> p, bool internalOp = false)
{
var w = new Writer();
w.U8(GpBinary16.Magic); w.U8(internalOp ? GpBinary16.MsgInternalOperationResponse : GpBinary16.MsgOperationResponse);
w.U8(opCode); w.I16(returnCode); w.Value(debug); w.Params(p);
return w.ToArray();
}
public static byte[] EventMessage(byte eventCode, IReadOnlyDictionary<byte, object?> p)
{
var w = new Writer();
w.U8(GpBinary16.Magic); w.U8(GpBinary16.MsgEvent);
w.U8(eventCode); w.Params(p);
return w.ToArray();
}
}
/// <summary>An already-encoded 1.8 value (type byte included), written back out byte for byte. How
/// a Photon Chat message is relayed: whatever the sender serialised - custom types included -
/// reaches the subscribers exactly as sent, never decoded and re-encoded.</summary>
public sealed class RawValue
{
public readonly byte[] Bytes;
public RawValue(byte[] bytes) { Bytes = bytes; }
public override string ToString() => $"raw[{Bytes.Length}]";
}
@@ -0,0 +1,97 @@
using System.Globalization;
using System.Numerics;
using System.Security.Cryptography;
namespace CampusNet.Protocol;
/// <summary>
/// The client's InitEncryption handshake: Diffie-Hellman over the 768-bit Oakley group (the client's
/// public key is 96 bytes - see the capture), shared secret hashed with SHA-256 into an AES-256-CBC
/// key (zero IV, PKCS7). The exact byte order Photon feeds SHA-256 isn't verifiable from a capture, so
/// the first encrypted message is tried against every candidate key and the one that yields a
/// parseable body is kept (see TryDecryptAdaptive).
/// </summary>
public sealed class PhotonCrypto
{
// RFC 2409 Oakley group 1 (768-bit).
private static readonly BigInteger Prime = BigInteger.Parse(
"0FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD129024E088A67CC74020BBEA63B139B22514A08798E3404DD" +
"EF9519B3CD3A431B302B0A6DF25F14374FE1356D6D51C245E485B576625E7EC6F44C42E9A63A3620FFFFFFFFFFFFFFFF",
NumberStyles.HexNumber);
public static int Generator = int.TryParse(Environment.GetEnvironmentVariable("CAMPUSNET_DH_GENERATOR"), out var g) ? g : 22;
private readonly BigInteger _priv;
public readonly byte[] PublicKey; // 96 bytes, big-endian unsigned
private BigInteger _shared;
private byte[]? _aesKey; // locked in once a decrypt succeeds
private List<byte[]>? _candidates;
public PhotonCrypto()
{
var rnd = RandomNumberGenerator.GetBytes(64);
_priv = new BigInteger(rnd, isUnsigned: true, isBigEndian: true) % (Prime - 2) + 1;
var pub = BigInteger.ModPow(Generator, _priv, Prime);
PublicKey = ToFixed(pub, 96);
}
public bool Ready => _aesKey != null || _candidates != null;
/// <summary>Derive the candidate AES keys from the client's public key.</summary>
public void DeriveSharedKey(byte[] clientPublicKey)
{
var a = new BigInteger(clientPublicKey, isUnsigned: true, isBigEndian: true);
_shared = BigInteger.ModPow(a, _priv, Prime);
var be = _shared.ToByteArray(isUnsigned: true, isBigEndian: true);
var le = _shared.ToByteArray(isUnsigned: true, isBigEndian: false);
var leSigned = _shared.ToByteArray(isUnsigned: false, isBigEndian: false);
var be96 = ToFixed(_shared, 96);
_candidates = new List<byte[]>();
foreach (var bytes in new[] { be, be96, le, leSigned })
{
var k = SHA256.HashData(bytes);
if (!_candidates.Any(c => c.AsSpan().SequenceEqual(k))) _candidates.Add(k);
}
_aesKey = null;
}
private static byte[] ToFixed(BigInteger v, int len)
{
var raw = v.ToByteArray(isUnsigned: true, isBigEndian: true);
if (raw.Length == len) return raw;
var outp = new byte[len];
if (raw.Length > len) Buffer.BlockCopy(raw, raw.Length - len, outp, 0, len);
else Buffer.BlockCopy(raw, 0, outp, len - raw.Length, raw.Length);
return outp;
}
private static byte[] AesRun(byte[] key, byte[] data, bool encrypt)
{
using var aes = Aes.Create();
aes.Key = key; aes.IV = new byte[16]; aes.Mode = CipherMode.CBC; aes.Padding = PaddingMode.PKCS7;
using var t = encrypt ? aes.CreateEncryptor() : aes.CreateDecryptor();
return t.TransformFinalBlock(data, 0, data.Length);
}
/// <summary>Decrypt an encrypted message body; on first use, pick the candidate key that yields a body `validate` accepts.</summary>
public byte[]? TryDecryptAdaptive(byte[] cipher, Func<byte[], bool> validate)
{
if (_aesKey != null)
{
try { return AesRun(_aesKey, cipher, false); } catch { return null; }
}
if (_candidates == null) return null;
foreach (var k in _candidates)
{
byte[] plain;
try { plain = AesRun(k, cipher, false); } catch { continue; }
if (validate(plain)) { _aesKey = k; return plain; }
}
return null;
}
public byte[]? Encrypt(byte[] plain)
{
var k = _aesKey ?? _candidates?.FirstOrDefault();
return k == null ? null : AesRun(k, plain, true);
}
}
@@ -0,0 +1,32 @@
using CampusNet.Net;
namespace CampusNet.Services;
/// <summary>
/// Room owners / co-owners and staff (developer or moderator flag) are not dropped for going quiet
/// the way everyone else is: once they join a room, the Campus server (localhost only) is asked
/// whether they own that room or are staff, and their connection's allowed silence is raised to
/// PhotonUdpServer.AfkExemptTimeoutMs (10 min). Everyone else keeps PeerTimeoutMs (60 s).
/// </summary>
public static class AfkExempt
{
private static readonly string Url = Environment.GetEnvironmentVariable("CAMPUSNET_AFKEXEMPT_URL") ?? "http://127.0.0.1:8080/photon/afkexempt";
private static readonly HttpClient Http = new() { Timeout = TimeSpan.FromSeconds(3) };
public static void Apply(Connection c, string room)
{
string? user = c.UserId;
if (string.IsNullOrEmpty(user) || !ulong.TryParse(user, out _)) return;
_ = Task.Run(async () =>
{
try
{
string answer = (await Http.GetStringAsync($"{Url}?id={Uri.EscapeDataString(user)}&room={Uri.EscapeDataString(room)}")).Trim();
if (answer.Length == 0 || answer == "0") return;
lock (c.Peer) c.Peer.TimeoutMs = PhotonUdpServer.AfkExemptTimeoutMs;
Log.Info($"{c.Tag}: {answer} in '{room}' - kept through {PhotonUdpServer.AfkExemptTimeoutMs / 60000} min of silence");
}
catch { /* Campus unreachable: they keep the normal timeout */ }
});
}
}
@@ -0,0 +1,52 @@
using System.Collections;
namespace CampusNet.Services;
/// <summary>
/// Room chat crosses the game server as RPC event 36 carrying the text wrapped as
/// "&lt;noparse&gt;...&lt;/noparse&gt;". A normal client has already run it through the Campus
/// server's chat filter, so what arrives is masked. Each chat message is held until the Campus
/// server (POST /photon/chatguard, localhost) says whether its filter would still change it -
/// if so the text skipped the filter (a chat prompt echoed into chat by a circuit, or a client
/// that never asked), the message is dropped and the sender disconnected; the Campus server
/// times them out. If the Campus server does not answer in time the message goes through:
/// chat must never stop working because of this check. CAMPUSNET_CHAT_GUARD=0 turns it off.
/// </summary>
public static class ChatGuard
{
public static readonly bool Enabled = Environment.GetEnvironmentVariable("CAMPUSNET_CHAT_GUARD") != "0";
private static readonly string Url = Environment.GetEnvironmentVariable("CAMPUSNET_CHATGUARD_URL") ?? "http://127.0.0.1:8080/photon/chatguard";
private static readonly HttpClient Http = new() { Timeout = TimeSpan.FromMilliseconds(1500) };
private const string Open = "<noparse>", Close = "</noparse>";
/// <summary>The chat text inside an event-36 payload, or null when it is not a chat message.</summary>
public static string? ChatText(object? o, int depth = 0)
{
if (o == null || depth > 4) return null;
if (o is string s)
return s.Length > Open.Length + Close.Length && s.StartsWith(Open, StringComparison.Ordinal) && s.EndsWith(Close, StringComparison.Ordinal)
? s.Substring(Open.Length, s.Length - Open.Length - Close.Length) : null;
if (o is Hashtable ht) { foreach (DictionaryEntry e in ht) { var t = ChatText(e.Value, depth + 1); if (t != null) return t; } return null; }
if (o is IEnumerable en and not byte[]) { int n = 0; foreach (var x in en) { if (n++ > 64) break; var t = ChatText(x, depth + 1); if (t != null) return t; } }
return null;
}
/// <summary>True only when the Campus server says the text skipped the filter; false on any doubt.</summary>
public static async Task<bool> IsBypass(string? userId, string room, string text)
{
if (string.IsNullOrEmpty(userId) || !ulong.TryParse(userId, out _)) return false;
try
{
using var body = new FormUrlEncodedContent(new[]
{
new KeyValuePair<string, string>("id", userId),
new KeyValuePair<string, string>("room", room),
new KeyValuePair<string, string>("text", text),
});
using var resp = await Http.PostAsync(Url, body);
return resp.IsSuccessStatusCode && (await resp.Content.ReadAsStringAsync()).Trim() == "bypass";
}
catch { return false; }
}
}
@@ -0,0 +1,253 @@
using CampusNet.Protocol;
namespace CampusNet.Services;
/// <summary>Photon Chat operation / parameter / event codes (the public Photon Chat SDK's values -
/// the game's copy of the SDK has these constants compiled away).</summary>
public static class ChatOp
{
public const byte Subscribe = 0, Unsubscribe = 1, Publish = 2, SendPrivate = 3, ChannelHistory = 4,
UpdateStatus = 5, AddFriends = 6, RemoveFriends = 7, GetProperties = 8, SetProperties = 9;
public static bool Is(byte op) => op <= SetProperties;
}
public static class ChatP
{
public const byte Channels = 0, Channel = 1, Messages = 2, Message = 3, Senders = 4, Sender = 5,
ChannelUserCount = 6, MsgId = 8, MsgIds = 9, Status = 10, Friends = 11, SkipMessage = 12,
HistoryLength = 14, SubscribeResults = 15, Properties = 22, ChannelSubscribers = 23,
UserId = 225;
}
public static class ChatEv
{
public const byte ChatMessages = 0, Users = 1, PrivateMessage = 2, FriendsList = 3, StatusUpdate = 4,
Subscribe = 5, Unsubscribe = 6, PropertiesChanged = 7, UserSubscribed = 8, UserUnsubscribed = 9;
}
/// <summary>
/// A Photon Chat frontend, riding on the MASTER port. Chat's operation codes (0-9) never collide
/// with LoadBalancing's (217+), and the name server already hands every client the master's
/// address, so a chat client lands here with no new port and no firewall change.
///
/// What uses it: event BROADCASTING (RecRoom.Broadcasting.BroadcastingManager). The broadcaster's
/// client publishes its movement, voice, room chat, desktop screen share and CV2 events to a chat
/// CHANNEL; players in the event's other instances subscribe to that channel and draw the
/// broadcaster from what arrives. So the job here is a faithful fan-out: every Publish reaches
/// every subscriber of the channel (sender included, as Photon does), payload byte for byte.
/// </summary>
public static class ChatService
{
private sealed class Channel
{
public readonly string Name;
public readonly List<Connection> Subs = new();
public readonly List<(int id, string sender, RawValue msg)> History = new();
public int NextMsgId;
public bool PublishSubscribers;
public int MaxSubscribers;
public long Published, Delivered;
public Channel(string name) { Name = name; }
}
private static readonly object Sync = new();
private static readonly Dictionary<string, Channel> Channels = new(StringComparer.Ordinal);
private static readonly Dictionary<Connection, HashSet<string>> Membership = new();
private const int HistoryKept = 20; // per channel, for a subscriber that asks for history
private const int MaxChannelsPerUser = 64;
private const int MaxChannelNameLength = 256;
private const byte PropMaxSubscribers = 255, PropPublishSubscribers = 254; // ChannelWellKnownProperties
public static void Handle(Connection c, GpBinary16.OperationRequest op)
{
switch (op.Code)
{
case ChatOp.Subscribe: Subscribe(c, op); return;
case ChatOp.Unsubscribe: Unsubscribe(c, op); return;
case ChatOp.Publish: Publish(c, op); return;
case ChatOp.SendPrivate: SendPrivate(c, op); return;
default:
// History, status, friends and properties: acknowledged. Nothing in the broadcast
// path depends on them; an error reply would only put a warning in the client log.
Log.Info($"{c.Tag}: chat op {op.Code} acknowledged {LoadBalancingApp.DescribeParams(op.Params)}");
c.SendResponse(op.Code, new());
return;
}
}
private static void Subscribe(Connection c, GpBinary16.OperationRequest op)
{
var names = (op.Params.GetValueOrDefault(ChatP.Channels) as string[]) ?? Array.Empty<string>();
int historyLength = LoadBalancingApp.ToInt(op.Params.GetValueOrDefault(ChatP.HistoryLength));
var opts = op.Params.GetValueOrDefault(ChatP.Properties) as System.Collections.Hashtable;
c.SendResponse(op.Code, new());
var results = new bool[names.Length];
var joined = new List<Channel>();
lock (Sync)
{
var mine = Membership.TryGetValue(c, out var m) ? m : Membership[c] = new();
for (int i = 0; i < names.Length; i++)
{
string name = names[i] ?? "";
if (name.Length == 0 || name.Length > MaxChannelNameLength) continue;
if (!mine.Contains(name) && mine.Count >= MaxChannelsPerUser) continue;
if (!Channels.TryGetValue(name, out var ch))
{
ch = Channels[name] = new Channel(name);
// Creation options are honoured by the subscriber that creates the channel.
if (opts != null)
{
ch.PublishSubscribers = opts[PropPublishSubscribers] is bool ps && ps;
ch.MaxSubscribers = LoadBalancingApp.ToInt(opts[PropMaxSubscribers]);
}
}
if (!ch.Subs.Contains(c))
{
if (ch.MaxSubscribers > 0 && ch.Subs.Count >= ch.MaxSubscribers) continue;
ch.Subs.Add(c);
}
mine.Add(name);
results[i] = true;
joined.Add(ch);
}
}
var ev = new Dictionary<byte, object?> { [ChatP.Channels] = names, [ChatP.SubscribeResults] = results };
if (names.Length == 1 && joined.Count == 1 && joined[0].PublishSubscribers)
{
string[] subs; lock (Sync) subs = joined[0].Subs.Select(s => s.UserId ?? "").ToArray();
ev[ChatP.ChannelSubscribers] = subs;
}
c.SendEvent(ChatEv.Subscribe, ev);
foreach (var ch in joined)
{
List<Connection> others; int count;
lock (Sync) { others = ch.Subs.Where(s => s != c).ToList(); count = ch.Subs.Count; }
Log.Info($"{c.Tag}: chat subscribed '{ch.Name}' ({count} subscriber(s){(ch.PublishSubscribers ? ", published" : "")})");
if (ch.PublishSubscribers)
foreach (var o in others)
o.SendEvent(ChatEv.UserSubscribed, new() { [ChatP.Channel] = ch.Name, [ChatP.UserId] = c.UserId ?? "" });
// History: -1 = all kept, N = the last N, 0 = none.
if (historyLength != 0)
{
List<(int id, string sender, RawValue msg)> h;
lock (Sync) h = historyLength < 0 ? ch.History.ToList() : ch.History.TakeLast(historyLength).ToList();
if (h.Count > 0)
c.SendEvent(ChatEv.ChatMessages, new()
{
[ChatP.Channel] = ch.Name,
[ChatP.Senders] = h.Select(x => x.sender).ToArray(),
[ChatP.Messages] = h.Select(x => (object?)x.msg).ToArray(),
[ChatP.MsgId] = h[^1].id
});
}
}
var refused = names.Where((n, i) => !results[i]).ToList();
if (refused.Count > 0) Log.Warn($"{c.Tag}: chat subscribe refused for [{string.Join(", ", refused)}]");
}
private static void Unsubscribe(Connection c, GpBinary16.OperationRequest op)
{
var names = (op.Params.GetValueOrDefault(ChatP.Channels) as string[]) ?? Array.Empty<string>();
c.SendResponse(op.Code, new());
foreach (var n in names) Leave(c, n);
c.SendEvent(ChatEv.Unsubscribe, new() { [ChatP.Channels] = names });
Log.Info($"{c.Tag}: chat unsubscribed [{string.Join(", ", names)}]");
}
private static void Leave(Connection c, string name)
{
Channel? ch; List<Connection> others = new();
lock (Sync)
{
if (Membership.TryGetValue(c, out var mine)) mine.Remove(name);
if (!Channels.TryGetValue(name, out ch)) return;
ch.Subs.Remove(c);
if (ch.PublishSubscribers) others = ch.Subs.ToList();
if (ch.Subs.Count == 0) Channels.Remove(name);
}
foreach (var o in others)
o.SendEvent(ChatEv.UserUnsubscribed, new() { [ChatP.Channel] = name, [ChatP.UserId] = c.UserId ?? "" });
}
private static void Publish(Connection c, GpBinary16.OperationRequest op)
{
string name = op.Params.GetValueOrDefault(ChatP.Channel) as string ?? "";
if (op.Raw == null || !op.Raw.TryGetValue(ChatP.Message, out var raw))
{
c.SendError(op.Code, Rc.InvalidOperation, "No message");
return;
}
if (!c.Charge(raw.Length, "chat publish")) return;
Channel? ch; List<Connection> targets; int id;
var msg = new RawValue(raw);
string sender = c.UserId ?? "";
lock (Sync)
{
if (!Channels.TryGetValue(name, out ch) || !ch.Subs.Contains(c))
{
// Photon only lets a subscriber publish to a channel.
c.SendError(op.Code, Rc.OperationNotAllowedInCurrentState, "Not subscribed to that channel");
return;
}
id = ++ch.NextMsgId;
ch.History.Add((id, sender, msg));
if (ch.History.Count > HistoryKept) ch.History.RemoveAt(0);
targets = ch.Subs.ToList();
ch.Published++; ch.Delivered += targets.Count;
if (ch.Published == 1 || ch.Published % 1000 == 0)
Log.Info($"{c.Tag}: chat '{name}' published {ch.Published} message(s), {ch.Delivered} delivered, {targets.Count} subscriber(s) (last {raw.Length}B)");
}
c.SendResponse(op.Code, new());
var bytes = GpBinary18.EventMessage(ChatEv.ChatMessages, new Dictionary<byte, object?>
{
[ChatP.Channel] = name,
[ChatP.Senders] = new[] { sender },
[ChatP.Messages] = new object?[] { msg },
[ChatP.MsgId] = id
});
foreach (var t in targets) t.Send(bytes);
}
private static void SendPrivate(Connection c, GpBinary16.OperationRequest op)
{
string to = op.Params.GetValueOrDefault(ChatP.UserId) as string ?? "";
if (op.Raw == null || !op.Raw.TryGetValue(ChatP.Message, out var raw) || to.Length == 0)
{
c.SendError(op.Code, Rc.InvalidOperation, "No message or target");
return;
}
if (!c.Charge(raw.Length, "chat private")) return;
List<Connection> targets;
lock (Sync) targets = Membership.Keys.Where(k => k.UserId == to || k == c).ToList();
c.SendResponse(op.Code, new());
var p = new Dictionary<byte, object?>
{
[ChatP.Sender] = c.UserId ?? "",
[ChatP.Message] = new RawValue(raw),
[ChatP.UserId] = to,
[ChatP.MsgId] = 0
};
foreach (var t in targets) t.SendEvent(ChatEv.PrivateMessage, p);
}
/// <summary>The connection is gone: out of every channel it was in.</summary>
public static void OnDisconnect(Connection c)
{
List<string> names;
lock (Sync)
{
if (!Membership.TryGetValue(c, out var mine)) return;
names = mine.ToList();
Membership.Remove(c);
}
foreach (var n in names) Leave(c, n);
if (names.Count > 0) Log.Info($"{c.Tag}: chat disconnected, left [{string.Join(", ", names)}]");
}
}
@@ -0,0 +1,66 @@
namespace CampusNet.Services;
/// <summary>Photon LoadBalancing operation / parameter / event codes (standard values; the ones in the capture are marked).</summary>
public static class Op
{
public const byte GetRegions = 220, Authenticate = 230, AuthenticateOnce = 231, JoinLobby = 229, LeaveLobby = 228,
CreateGame = 227, JoinGame = 226, JoinRandomGame = 225, Leave = 254, RaiseEvent = 253, SetProperties = 252,
GetProperties = 251, ChangeGroups = 248, FindFriends = 222, GetLobbyStats = 221, WebRpc = 219,
ServerSettings = 218, GetGameList = 217;
}
public static class P
{
public const byte RoomName = 255, ActorNr = 254, TargetActorNr = 253, ActorList = 252, Properties = 251,
Broadcast = 250, PlayerProperties = 249, GameProperties = 248, Cache = 247, ReceiverGroup = 246,
Data = 245, Code = 244, CleanupCacheOnLeave = 241, Group = 240, Remove = 239, Add = 238,
PublishUserId = 239, EmptyRoomTtl = 236, PlayerTtl = 235, IsInactive = 233, CheckUserOnJoin = 232,
ExpectedValues = 231, Address = 230, PeerCount = 229, GameCount = 228, MasterPeerCount = 227,
UserId = 225, ApplicationId = 224, MatchMakingType = 223, GameList = 222, Secret = 221,
AppVersion = 220, AzureNodeInfo = 219, AzureLocalNodeId = 218, ClientAuthenticationType = 217,
ClientAuthenticationData = 216, JoinMode = 215, ClientAuthenticationParams = 214, LobbyName = 213,
LobbyType = 212, LobbyStats = 211, Region = 210, UriPath = 209, RpcCallParams = 208,
RpcCallRetCode = 207, RpcCallRetMessage = 206, CacheSliceIndex = 205, Plugins = 204,
MasterClientId = 203, RoomOptionFlags = 191;
}
public static class Ev
{
public const byte Join = 255, Leave = 254, PropertiesChanged = 253, ErrorInfo = 251, CacheSliceChanged = 224,
AppStats = 226, GameList = 230, GameListUpdate = 229;
}
/// <summary>Well-known game (room) property keys.</summary>
public static class GameProp
{
public const byte MaxPlayers = 255, IsVisible = 254, IsOpen = 253, PlayerCount = 252, Removed = 251,
PropsListedInLobby = 250, CleanupCacheOnLeave = 249, MasterClientId = 248, ExpectedUsers = 247,
PlayerTtl = 246, EmptyRoomTtl = 245;
}
/// <summary>Well-known actor (player) property keys.</summary>
public static class PlayerProp
{
public const byte NickName = 255, IsInactive = 254, UserId = 253;
}
public static class Rc
{
public const short Ok = 0, OperationNotAllowedInCurrentState = -3, InvalidOperation = -2, InternalServerError = -1,
InvalidAuthentication = 32767, GameIdAlreadyExists = 32766, GameFull = 32765, GameClosed = 32764,
NoRandomMatchFound = 32760, GameDoesNotExist = 32758, MaxCcuReached = 32757, InvalidRegion = 32756,
CustomAuthenticationFailed = 32755, AuthenticationTicketExpired = 32753, PluginReportedError = 32752,
PluginMismatch = 32751, JoinFailedPeerAlreadyJoined = 32750, JoinFailedFoundInactiveJoiner = 32749,
JoinFailedWithRejoinerNotFound = 32748, JoinFailedFoundExcludedUserId = 32747,
JoinFailedFoundActiveJoiner = 32746, HttpLimitReached = 32745, ExternalHttpCallFailed = 32744,
SlotError = 32742, InvalidEncryptionParameters = 32741;
}
public static class Cache
{
public const byte DoNotCache = 0, MergeCache = 1, ReplaceCache = 2, RemoveCache = 3, AddToRoomCache = 4,
AddToRoomCacheGlobal = 5, RemoveFromRoomCache = 6, RemoveFromRoomCacheForActorsLeft = 7,
SliceIncreaseIndex = 10, SliceSetIndex = 11, SlicePurgeIndex = 12, SlicePurgeUpToIndex = 13;
}
public static class Receivers { public const byte Others = 0, All = 1, MasterClient = 2; }
@@ -0,0 +1,158 @@
using System.Text;
using CampusNet.Net;
using CampusNet.Protocol;
namespace CampusNet.Services;
/// <summary>
/// Per-peer state above the transport: protocol init, the encryption handshake, and dispatch of
/// decoded operation requests to the role (name/master/game) handler. Replies go back plaintext
/// (the client only decrypts messages carrying the 0x80 flag).
/// </summary>
public sealed class Connection
{
public readonly PhotonUdpServer Server;
public readonly PhotonUdpServer.Peer Peer;
public string AppName = "";
public bool InitDone;
public bool Proto18; // client spoke GpBinary 1.8 in its Init (Photon Voice/Chat libs); PUN is 1.6
public PhotonCrypto? Crypto;
public string? UserId; // set by Authenticate
public string NickName = "";
public Room? Room; // game server only
public Actor? Actor;
public bool InLobby;
public readonly DateTime ConnectedAt = DateTime.UtcNow;
public long Sent, Received;
public readonly TokenBucket Ops = new(Guard.OpsPerSec, Guard.OpsBurst);
public readonly TokenBucket EventBytes = new(Guard.EventBytesPerSec, Guard.EventBytesBurst);
/// <summary>
/// Charge one operation (and, for RaiseEvent, its payload) against this connection's budget.
/// False = drop it. A connection that keeps exceeding the budget is kicked and its IP cooled -
/// that is the RPC-spam / "cheer crash" defence: the flood never reaches the other players.
/// </summary>
public bool Charge(int payloadBytes, string what)
{
long now = Environment.TickCount64;
bool ok = Ops.Take(1, now);
if (ok && payloadBytes > 0) ok = EventBytes.Take(payloadBytes, now);
if (ok) return true;
var b = Ops.DroppedSince > 0 ? Ops : EventBytes;
if (b.DroppedSince == 1) Log.Warn($"{Tag}: over budget ({what}) - dropping (ops {Guard.OpsPerSec}/s, event bytes {Guard.EventBytesPerSec}/s)");
if (b.DroppedSince >= Guard.OpKickAfter)
{
Log.Warn($"{Tag}: kicked - {b.Dropped} operations dropped, {b.DroppedSince} in a row ({what})");
Server.Kick(Peer, "operation flood", Guard.OpFloodBanSec); // shorter than the transport ban: a shared NAT is punished less
}
return false;
}
public Connection(PhotonUdpServer server, PhotonUdpServer.Peer peer) { Server = server; Peer = peer; }
public string Tag => $"{Server.Name}:{Peer}{(UserId != null ? " u" + UserId : "")}{(Actor != null ? " a" + Actor.Nr : "")}";
// ---------------------------------------------------------------- sending
public void SendResponse(byte opCode, short rc, string? debug, Dictionary<byte, object?> p, bool reliable = true)
{
var msg = Proto18 ? GpBinary18.OperationResponseMessage(opCode, rc, debug, p) : GpBinary16.OperationResponseMessage(opCode, rc, debug, p);
Send(msg, reliable);
}
public void SendResponse(byte opCode, Dictionary<byte, object?> p) => SendResponse(opCode, Rc.Ok, null, p);
public void SendError(byte opCode, short rc, string debug) => SendResponse(opCode, rc, debug, new());
public void SendEvent(byte code, Dictionary<byte, object?> p, bool reliable = true)
=> Send(Proto18 ? GpBinary18.EventMessage(code, p) : GpBinary16.EventMessage(code, p), reliable);
public void Send(byte[] message, bool reliable = true)
{
Sent++;
if (reliable) Server.SendReliable(Peer, message); else Server.SendUnreliable(Peer, message);
}
public void Disconnect(string why) => Server.Disconnect(Peer, why);
// ---------------------------------------------------------------- receiving
/// <summary>Decode one message; returns the operation to handle, or null when handled here (init / encryption).</summary>
public GpBinary16.OperationRequest? Decode(byte[] message, out bool encryptedRequest)
{
encryptedRequest = false;
Received++;
if (message.Length < 2 || message[0] != GpBinary16.Magic)
{
Log.Warn($"{Tag}: bad message magic {message[0]:x2}"); return null;
}
byte type = message[1];
bool encrypted = (type & GpBinary16.EncryptedFlag) != 0;
type &= 0x7F;
ReadOnlySpan<byte> body = message.AsSpan(2);
if (type == GpBinary16.MsgInit)
{
// protoMajor, protoMinor, 1e 41 06 19 00, app name (32 bytes)
Proto18 = body.Length >= 2 && body[1] >= 8;
AppName = body.Length >= 39 ? Encoding.ASCII.GetString(body.Slice(7, 32)).TrimEnd('\0') : "";
InitDone = true;
Send(GpBinary16.InitResponse());
Log.Info($"{Tag}: init app='{AppName}' protocol 1.{(Proto18 ? 8 : 6)}");
return null;
}
byte[] plain;
if (encrypted)
{
if (Crypto == null) { Log.Warn($"{Tag}: encrypted message before key exchange"); return null; }
var dec = Crypto.TryDecryptAdaptive(body.ToArray(), b => LooksLikeOperation(b));
if (dec == null) { Log.Warn($"{Tag}: could not decrypt message type {type} ({body.Length} bytes) - no candidate key parsed"); return null; }
plain = dec; encryptedRequest = true;
}
else plain = body.ToArray();
if (type == GpBinary16.MsgInternalOperationRequest)
{
var iop = ReadOp(plain);
if (iop.Code == 0) // InitEncryption: param 1 = client's DH public key
{
Crypto = new PhotonCrypto();
if (iop.Params.TryGetValue(1, out var k) && k is byte[] clientKey) Crypto.DeriveSharedKey(clientKey);
var kp = new Dictionary<byte, object?> { [1] = Crypto.PublicKey };
Send(Proto18 ? GpBinary18.OperationResponseMessage(0, 0, null, kp, internalOp: true) : GpBinary16.OperationResponseMessage(0, 0, null, kp, internalOp: true));
Log.Info($"{Tag}: key exchange done");
}
else if (iop.Code == 1) // Ping (internal) - answer with the client's timestamp echoed
{
Send(Proto18 ? GpBinary18.OperationResponseMessage(1, 0, null, iop.Params, internalOp: true) : GpBinary16.OperationResponseMessage(1, 0, null, iop.Params, internalOp: true));
}
else Log.Warn($"{Tag}: internal op {iop.Code} ignored");
return null;
}
if (type == GpBinary16.MsgOperationRequest)
return ReadOp(plain);
Log.Warn($"{Tag}: unexpected message type {type}");
return null;
}
private GpBinary16.OperationRequest ReadOp(byte[] plain)
{
var op = Proto18 ? GpBinary18.ReadOperationRequest(plain) : GpBinary16.ReadOperationRequest(plain);
op.PayloadBytes = plain.Length;
return op;
}
private bool LooksLikeOperation(byte[] b)
{
try { var op = ReadOp(b); return op.Params.Count <= 64; }
catch { return false; }
}
}
public static class Log
{
public static bool Debug = Environment.GetEnvironmentVariable("CAMPUSNET_DEBUG") == "1";
public static void Info(string s) => Console.WriteLine($"{DateTime.UtcNow:HH:mm:ss.fff} {s}");
public static void Warn(string s) => Console.WriteLine($"{DateTime.UtcNow:HH:mm:ss.fff} WARN {s}");
public static void Dbg(string s) { if (Debug) Console.WriteLine($"{DateTime.UtcNow:HH:mm:ss.fff} dbg {s}"); }
}
@@ -0,0 +1,729 @@
using System.Collections;
using CampusNet.Net;
using CampusNet.Protocol;
namespace CampusNet.Services;
/// <summary>The game-server side: rooms, actors, events (with PUN's event cache), properties, groups.</summary>
public static class GameService
{
public static void Handle(Connection c, GpBinary16.OperationRequest op, bool reliable)
{
int payload = op.Code == Op.RaiseEvent ? op.PayloadBytes : 0;
if (payload > Guard.MaxEventBytes)
{
Log.Warn($"{c.Tag}: event of {payload} bytes refused (cap {Guard.MaxEventBytes})");
c.SendError(op.Code, Rc.InvalidOperation, "Event too large");
return;
}
if (!c.Charge(payload, $"op {op.Code}")) return;
switch (op.Code)
{
case Op.CreateGame:
case Op.JoinGame: Join(c, op); return;
case Op.Leave: Leave(c, op); return;
case Op.RaiseEvent: RaiseEvent(c, op, reliable); return;
case Op.SetProperties: SetProperties(c, op); return;
case Op.GetProperties: GetProperties(c, op); return;
case Op.ChangeGroups: ChangeGroups(c, op); return;
case Op.ServerSettings: c.SendResponse(op.Code, new()); return;
default:
Log.Warn($"{c.Tag}: game op {op.Code} not implemented {LoadBalancingApp.DescribeParams(op.Params)}");
c.SendError(op.Code, Rc.InvalidOperation, "Operation not supported");
return;
}
}
// ---------------------------------------------------------------- join / create
private static void Join(Connection c, GpBinary16.OperationRequest op)
{
if (c.Room != null) { c.SendError(op.Code, Rc.OperationNotAllowedInCurrentState, "Already in a game"); return; }
string name = op.Params.GetValueOrDefault(P.RoomName) as string ?? "";
if (name.Length == 0) { c.SendError(op.Code, Rc.InvalidOperation, "Missing game id"); return; }
// Kicked from this instance (Campus InstanceBlocks): the client reconnects to its old room
// on its own, past matchmaking - refuse it here as well as on the master (JoinBlocks).
if (JoinBlocks.IsBlocked(c.UserId, name))
{
Log.Info($"{c.Tag}: refused join to '{name}' - kicked from that instance");
c.SendError(op.Code, Rc.GameClosed, "Game closed"); return;
}
int joinMode = LoadBalancingApp.ToInt(op.Params.GetValueOrDefault(P.JoinMode)); // 0 default, 1 CreateIfNotExists, 2 JoinOrRejoin, 3 RejoinOnly
Room room;
bool created;
if (op.Code == Op.CreateGame) room = Rooms.GetOrCreate(name, out created);
else
{
var existing = Rooms.Get(name);
if (existing == null && joinMode != 1) { c.SendError(op.Code, Rc.GameDoesNotExist, "Game does not exist"); return; }
room = existing ?? Rooms.GetOrCreate(name, out _);
created = existing == null;
}
Actor actor;
var joinerProps = op.Params.GetValueOrDefault(P.PlayerProperties) as Hashtable ?? new Hashtable();
var cachedEvents = new List<CachedEvent>();
var others = new List<Connection>();
lock (room.Sync)
{
// A NEW session (any join but RejoinOnly) from a user who still has an actor here: the
// old game never disconnected (a hard quit or crash - staff connections are kept through
// 10 min of silence by AfkExempt, so they outlive a game restart). That ghost is gone,
// the way Photon would have timed it out: taking over its actor handed the new game the
// ghost's own cached spawns, so it built a second Maker Pen with the same view id
// ("NetworkView ID duplicate found: 100029"), whose teardown then threw on every leave
// - the client never finished leaving and every room load after the dorm hung at
// "PREPARING 0%" (u6, 2026-09-27 14:43-14:59). An explicit rejoin (JoinMode 3) is the
// one case that legitimately resumes its actor.
var ghost = joinMode == 3 ? null : room.FindByUserId(c.UserId!);
if (ghost != null)
{
if (ghost.Conn != null && ghost.Conn != c)
{
var old = ghost.Conn; old.Room = null; old.Actor = null;
try { old.Disconnect("replaced by a new session"); } catch { }
}
ghost.Conn = null;
room.Actors.Remove(ghost.Nr);
int dropped = room.Cache.RemoveAll(e => !e.Global && e.ActorNr == ghost.Nr);
if (room.Actors.Count == 0)
{
// It was alone: Photon would have closed the empty room - start it fresh.
room.Cache.Clear(); room.Props.Clear(); room.NextActorNr = 1; room.MasterOverride = 0;
room.PlayerTtl = room.EmptyRoomTtl = 0; room.CleanupCacheOnLeave = false;
Log.Warn($"{c.Tag}: user already in '{name}' as actor {ghost.Nr} from an old session - removed it and reset the room (it was alone)");
}
else
{
// Everyone else sees the ghost leave (before the new actor's join event below).
var leaveEv = new Dictionary<byte, object?> { [P.ActorNr] = ghost.Nr, [P.IsInactive] = false, [P.MasterClientId] = room.MasterClientId };
foreach (var a in room.Actors.Values)
if (a.Conn != null && !a.Inactive) a.Conn.SendEvent(Ev.Leave, leaveEv);
Log.Warn($"{c.Tag}: user already in '{name}' as actor {ghost.Nr} from an old session - removed it ({dropped} of its cached events) and joining as a new actor");
}
}
if (created || room.Actors.Count == 0 && room.Props.Count == 0)
{
if (op.Params.GetValueOrDefault(P.GameProperties) is Hashtable gp) Room.Merge(room.Props, gp);
room.PlayerTtl = LoadBalancingApp.ToInt(op.Params.GetValueOrDefault(P.PlayerTtl));
room.EmptyRoomTtl = LoadBalancingApp.ToInt(op.Params.GetValueOrDefault(P.EmptyRoomTtl));
if (room.Props[GameProp.PlayerTtl] is int pt) room.PlayerTtl = pt;
if (room.Props[GameProp.EmptyRoomTtl] is int et) room.EmptyRoomTtl = et;
if (op.Params.GetValueOrDefault(P.CleanupCacheOnLeave) is bool cl) room.CleanupCacheOnLeave = cl;
if (room.Props[GameProp.CleanupCacheOnLeave] is bool cl2) room.CleanupCacheOnLeave = cl2;
Log.Info($"{c.Tag}: created room '{name}' props={GpBinary16.Describe(room.Props)}");
}
var rejoin = room.FindByUserId(c.UserId!);
if (rejoin != null)
{
if (!rejoin.Inactive && rejoin.Conn != null && rejoin.Conn != c)
{
// Same user connected twice: the newer connection wins (the old one is stale).
Log.Warn($"{c.Tag}: user already in '{name}' as actor {rejoin.Nr} - replacing the old connection");
var old = rejoin.Conn; old.Room = null; old.Actor = null;
try { old.Disconnect("replaced by a new connection"); } catch { }
}
actor = rejoin;
actor.Inactive = false; actor.Conn = c;
actor.ActiveSince = DateTime.UtcNow; actor.LoadSignal = false; actor.StuckReported = false;
Room.Merge(actor.Props, joinerProps);
}
else
{
if (!room.IsOpen && joinMode != 3) { c.SendError(op.Code, Rc.GameClosed, "Game closed"); return; }
if (room.MaxPlayers > 0 && room.ActiveCount >= room.MaxPlayers) { c.SendError(op.Code, Rc.GameFull, "Game full"); return; }
actor = new Actor { Nr = room.NextActorNr++, UserId = c.UserId!, Conn = c, CreatedRoom = room.ActiveCount == 0 };
Room.Merge(actor.Props, joinerProps);
if (!actor.Props.ContainsKey(PlayerProp.NickName) && c.NickName.Length > 0) actor.Props[PlayerProp.NickName] = c.NickName;
room.Actors[actor.Nr] = actor;
}
c.Room = room; c.Actor = actor;
foreach (var a in room.Actors.Values)
if (a.Conn != null && a.Conn != c && !a.Inactive) others.Add(a.Conn);
cachedEvents.AddRange(room.Cache);
foreach (var a in room.Actors.Values) cachedEvents.AddRange(a.OwnCache);
// Response: my actor number, everyone's properties, the room's, and the active actor list.
c.SendResponse(op.Code, new Dictionary<byte, object?>
{
[P.ActorNr] = actor.Nr,
[P.ActorList] = room.ActorList(),
[P.PlayerProperties] = room.AllPlayerProps(),
[P.GameProperties] = room.GamePropsForClient(),
[P.RoomName] = name
});
// Join event to everyone including the joiner (PUN sets up its own player from it).
var joinEv = new Dictionary<byte, object?>
{
[P.ActorNr] = actor.Nr,
[P.PlayerProperties] = Room.PlayerPropsForClient(actor),
[P.ActorList] = room.ActorList()
};
c.SendEvent(Ev.Join, joinEv);
foreach (var o in others) o.SendEvent(Ev.Join, joinEv);
}
// Replay the cache so the late joiner sees every instantiate / cached RPC in order.
// EVERY global event goes out as coming from the room (actor 0), the way Photon owns global
// events. Rec Room's buffered room load (all global, cache=5) only accepts messages from the
// current master or from actor 0 ("Received buffered message from non-master client [1]
// Current Master is [2]. Discarding..."), so replaying under the real sender hung the
// joiner at 25% whenever that sender was no longer master:
// * the sender had LEFT (2026-09-27 11:11, room-2-2-461102059, 53 events discarded);
// * the sender was still there but had HANDED master on (2026-09-27 10:20 EDT,
// room-2-2-49039038: a1 -> a2 when a player dropped, 54 events discarded).
// Only the replay's sender changes: the cache keeps the real sender, so RemoveFromRoomCache
// filters still match (see CachedEvent.ActorNr). Non-global events keep their sender.
foreach (var ev in cachedEvents)
c.SendEvent(ev.Code, EventParams(ev.Global ? 0 : ev.ActorNr, ev.Data));
Log.Info($"{c.Tag}: joined '{name}' as actor {actor.Nr} ({room.ActiveCount} active, {cachedEvents.Count} cached events replayed)");
JoinCheck.Observe(c.UserId, name, c.Tag);
AfkExempt.Apply(c, name);
}
private static Dictionary<byte, object?> EventParams(int senderActor, object? data)
{
var p = new Dictionary<byte, object?> { [P.ActorNr] = senderActor };
if (data != null) p[P.Data] = data;
return p;
}
// ---------------------------------------------------------------- leave
private static void Leave(Connection c, GpBinary16.OperationRequest op)
{
bool inactive = op.Params.GetValueOrDefault(P.IsInactive) is bool b && b;
RemoveFromRoom(c, inactive, "leave");
c.SendResponse(op.Code, new());
}
public static void OnConnectionLost(Connection c) => RemoveFromRoom(c, becomeInactive: true, why: "disconnect");
private static void RemoveFromRoom(Connection c, bool becomeInactive, string why)
{
var room = c.Room; var actor = c.Actor;
if (room == null || actor == null) return;
c.Room = null; c.Actor = null;
List<Connection> others;
int master; bool removed;
lock (room.Sync)
{
if (actor.Conn != c) return; // already superseded
actor.Conn = null;
bool keep = becomeInactive && room.PlayerTtl != 0;
if (keep) { actor.Inactive = true; actor.InactiveSince = DateTime.UtcNow; removed = false; }
else
{
room.Actors.Remove(actor.Nr); removed = true;
// Cached events by this actor go only if the room asked for CleanupCacheOnLeave
// (Rec Room never does - see Room.CleanupCacheOnLeave) and they weren't "global".
if (room.CleanupCacheOnLeave) room.Cache.RemoveAll(e => !e.Global && e.ActorNr == actor.Nr);
}
master = room.MasterClientId;
others = room.Actors.Values.Where(a => a.Conn != null && !a.Inactive).Select(a => a.Conn!).ToList();
if (room.ActiveCount == 0) room.EmptySince = DateTime.UtcNow;
}
var ev = new Dictionary<byte, object?>
{
[P.ActorNr] = actor.Nr,
[P.IsInactive] = !removed,
[P.MasterClientId] = master
};
foreach (var o in others) o.SendEvent(Ev.Leave, ev);
Log.Info($"{c.Tag}: left '{room.Name}' ({why}, {(removed ? "removed" : "inactive")}), master now {master}");
MaybeRemoveRoom(room);
}
private static void MaybeRemoveRoom(Room room)
{
lock (room.Sync)
{
if (room.Actors.Values.Any(a => !a.Inactive)) return;
if (room.EmptyRoomTtl > 0 && (DateTime.UtcNow - room.EmptySince).TotalMilliseconds < room.EmptyRoomTtl) return;
if (room.Actors.Values.Any(a => a.Inactive) && room.PlayerTtl < 0) return; // PlayerTtl -1 = keep forever
}
Rooms.Remove(room.Name);
Log.Info($"room '{room.Name}' removed (empty)");
}
private static DateTime _lastStats = DateTime.MinValue;
/// <summary>Periodic: expire inactive actors past PlayerTtl and empty rooms past EmptyRoomTtl.</summary>
public static void Sweep()
{
bool stats = (DateTime.UtcNow - _lastStats).TotalSeconds >= 15;
if (stats) _lastStats = DateTime.UtcNow;
foreach (var room in Rooms.All())
{
// Every 15 s, for rooms with company: who raised what, and where it went. This is the
// trace for "the other player doesn't move" - it shows whether the mover's events
// arrive at all, whether they are unreliable, which interest group they carry, and
// whether anyone was subscribed to receive them.
if (stats)
{
lock (room.Sync)
{
if (room.ActiveCount >= 2)
{
foreach (var a in room.Actors.Values)
{
if (a.Raised == 0 && a.Conn == null) continue;
string codes = string.Join(" ", a.RaisedByCode.OrderByDescending(kv => kv.Value).Take(8).Select(kv => $"{kv.Key}:{kv.Value}"));
string groups = a.RaisedByGroup.Count == 0 ? "-" : string.Join(" ", a.RaisedByGroup.OrderByDescending(kv => kv.Value).Take(6).Select(kv => $"g{kv.Key}:{kv.Value}"));
string subs = a.Groups.Count == 0 ? "none" : (a.Groups.Count > 12 ? $"{a.Groups.Count} groups" : string.Join(",", a.Groups.OrderBy(g => g)));
Log.Info($"stats '{room.Name}' actor {a.Nr} u{a.UserId}{(a.Inactive ? " (inactive)" : "")}: raised {a.Raised} ({a.RaisedUnreliable} unreliable), delivered {a.Delivered}, no-recipient {a.DroppedNoRecipient}, codes [{codes}], sent-in-groups [{groups}], subscribed [{subs}], recv {a.Conn?.Sent ?? 0} msgs, rtt {a.Conn?.Peer.Rtt ?? 0}ms");
a.Raised = a.RaisedUnreliable = a.Delivered = a.DroppedNoRecipient = 0;
a.RaisedByCode.Clear(); a.RaisedByGroup.Clear();
}
}
}
}
// Stuck-load watchdog: a joiner that has shown no sign of finishing its load (StuckLoad).
List<(string user, int secs)>? stuck = null;
lock (room.Sync)
{
foreach (var a in room.Actors.Values)
{
if (a.Inactive || a.Conn == null || a.LoadSignal || a.StuckReported || a.CreatedRoom) continue;
int secs = (int)(DateTime.UtcNow - a.ActiveSince).TotalSeconds;
if (secs < StuckLoad.AfterSeconds) continue;
a.StuckReported = true;
(stuck ??= new()).Add((a.UserId, secs));
}
}
if (stuck != null) foreach (var (user, secs) in stuck) StuckLoad.Report(user, room.Name, secs);
List<Connection> others = new(); List<int> expired = new(); int master;
lock (room.Sync)
{
foreach (var a in room.Actors.Values.Where(a => a.Inactive).ToList())
{
if (room.PlayerTtl > 0 && (DateTime.UtcNow - a.InactiveSince).TotalMilliseconds > room.PlayerTtl)
{
room.Actors.Remove(a.Nr); if (room.CleanupCacheOnLeave) room.Cache.RemoveAll(e => !e.Global && e.ActorNr == a.Nr); expired.Add(a.Nr);
}
}
master = room.MasterClientId;
if (expired.Count > 0) others = room.Actors.Values.Where(a => a.Conn != null && !a.Inactive).Select(a => a.Conn!).ToList();
}
foreach (var nr in expired)
foreach (var o in others)
o.SendEvent(Ev.Leave, new Dictionary<byte, object?> { [P.ActorNr] = nr, [P.IsInactive] = false, [P.MasterClientId] = master });
MaybeRemoveRoom(room);
}
}
// ---------------------------------------------------------------- events
private static readonly string[] TraceRooms = (Environment.GetEnvironmentVariable("CAMPUSNET_TRACE_ROOM") ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
private static readonly HashSet<string> TraceUsers = new((Environment.GetEnvironmentVariable("CAMPUSNET_TRACE_USER") ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries));
private static readonly string TraceRoom = (TraceRooms.Length > 0 || TraceUsers.Count > 0) ? "on" : "";
private static bool Traced(Room room) => TraceRooms.Any(t => room.Name.Contains(t, StringComparison.OrdinalIgnoreCase));
// A traced user's rooms are traced too (private instances carry no room id in their name).
private static bool Traced(Room room, Actor me) => Traced(room) || TraceUsers.Contains(me.UserId ?? "") || room.Actors.Values.Any(a => TraceUsers.Contains(a.UserId ?? ""));
/// <summary>The actor numbers in a RaiseEvent's target list, whatever container they arrived in.</summary>
/// <summary>Whether an event payload carries readable UTF-16 text (typed chat) - checked on RPCs
/// (code 36) only, so any room's chat can be traced without logging every event.</summary>
private static bool HasUtf16Text(object? o, int depth = 0)
{
if (o == null || depth > 4) return false;
if (o is string str) return str.Length >= 3 && str.Any(char.IsLetter) && str.All(ch => ch >= 32);
if (o is byte[] b)
{
int run = 0;
for (int i = 0; i + 1 < b.Length; i += 2)
{
if (b[i + 1] == 0 && b[i] >= 32 && b[i] < 127) { if (++run >= 4) return true; }
else run = 0;
}
return false;
}
if (o is Hashtable ht) { foreach (DictionaryEntry e in ht) if (HasUtf16Text(e.Value, depth + 1)) return true; return false; }
if (o is System.Collections.IEnumerable en) { int n = 0; foreach (var x in en) { if (n++ > 64) break; if (HasUtf16Text(x, depth + 1)) return true; } }
return false;
}
private static string Trunc(string s, int max) => s.Length <= max ? s : s.Substring(0, max) + "…";
private static int[]? ActorTargets(object? v)
{
switch (v)
{
case null: return null;
case int[] n: return n;
case byte[] b: return b.Select(x => (int)x).ToArray();
case short[] sh: return sh.Select(x => (int)x).ToArray();
case TypedArray ta: return ta.Items.Select(LoadBalancingApp.ToInt).ToArray();
case object?[] oa: return oa.Select(LoadBalancingApp.ToInt).ToArray();
default:
Log.Warn($"RaiseEvent: unrecognised target-actor list type {v.GetType().Name} - treated as none");
return null;
}
}
private static void RaiseEvent(Connection c, GpBinary16.OperationRequest op, bool reliable)
{
var room = c.Room; var me = c.Actor;
if (room == null || me == null) { c.SendError(op.Code, Rc.OperationNotAllowedInCurrentState, "Not in a game"); return; }
byte code = (byte)LoadBalancingApp.ToInt(op.Params.GetValueOrDefault(P.Code));
var data = op.Params.GetValueOrDefault(P.Data);
byte cache = (byte)LoadBalancingApp.ToInt(op.Params.GetValueOrDefault(P.Cache));
byte receivers = (byte)LoadBalancingApp.ToInt(op.Params.GetValueOrDefault(P.ReceiverGroup));
byte group = (byte)LoadBalancingApp.ToInt(op.Params.GetValueOrDefault(P.Group));
// Target actors (252). PUN spells this int[] ('n'), which is all that was matched before;
// a typed array of ints / an object[] / a byte[] decodes to something else and used to be
// silently treated as "no targets" - the event then went by receiver group instead.
var targets = ActorTargets(op.Params.GetValueOrDefault(P.ActorList));
// A gift box spawned into the room: let every other client show its message and box type
// (they only do when the package says IsSignatureValid - see GiftBoxRelay).
if (code == 29 && StuckLoad.ContainsString(data, "(GiftBox)"))
{
GiftBoxRelay.Capture(data, room.Name, c.UserId);
int flipped = GiftBoxRelay.MarkValid(data);
if (flipped > 0 && !room.LoggedGiftBoxValid) { room.LoggedGiftBoxValid = true; Log.Info($"{c.Tag}: gift box spawn in '{room.Name}' marked valid for the other players"); }
}
// A loaded client streams transforms (28); a joiner's first act once the room state is in
// is a request to the master client (receiver group 2). Either one means it is not stuck.
if (code == 28 || receivers == 2) me.LoadSignal = true;
var recipients = new List<Connection>();
lock (room.Sync)
{
bool isMaster = me.Nr == room.MasterClientId;
switch (cache)
{
case Cache.AddToRoomCache:
case Cache.AddToRoomCacheGlobal:
// A gift box someone opens is spawned into the room (ev 29 "(GiftBox)") and kept in
// the cache, so every later joiner replays it while loading - and on their client
// another player's box fails its package signature ("Bad signature for remote gift
// package"). Joins stuck at 25% started right after boxes were cached (Rec Center,
// 2026-09-26). Boxes are short-lived: deliver them live, never keep them.
if (code == 29 && StuckLoad.ContainsString(data, "(GiftBox)"))
{
if (!room.LoggedGiftBoxSkip) { room.LoggedGiftBoxSkip = true; Log.Info($"{c.Tag}: gift box spawns in '{room.Name}' are delivered live but not kept in the room cache"); }
break;
}
if (room.Cache.Count >= Guard.MaxRoomCache)
{
if (room.Cache.Count == Guard.MaxRoomCache) Log.Warn($"{c.Tag}: room '{room.Name}' cache full ({Guard.MaxRoomCache}) - new cached events are delivered but not kept");
break;
}
room.Cache.Add(new CachedEvent { ActorNr = me.Nr, Global = cache == Cache.AddToRoomCacheGlobal, Code = code, Data = data, Group = group }); break;
case Cache.RemoveFromRoomCache:
{
// Photon semantics: ANY actor removes the cached events matching the filter (code,
// data keys, and the target list read as SENDERS). This used to let only the
// master remove other actors' events - and Rec Room's buffered-event system clears
// a game-state slice from whichever client ends it, so those clears were silently
// dropped and a late joiner got the old round / team / spawn state replayed (the
// Dodgeball "join mid-game and spawn on top of everyone" bug).
// Event code 0 on a removal means "any code" (PUN's remove-all-of-a-player).
byte? codeFilter = op.Params.ContainsKey(P.Code) && code != 0 ? code : null;
int foreign = isMaster ? 0 : room.Cache.Count(e => e.ActorNr != me.Nr && CacheFilterMatch(e, targets, codeFilter, data as Hashtable));
int removed = room.Cache.RemoveAll(e => CacheFilterMatch(e, targets, codeFilter, data as Hashtable));
if (foreign > 0 && !room.LoggedForeignRemove)
{
room.LoggedForeignRemove = true;
Log.Info($"{c.Tag}: non-master a{me.Nr} removed {foreign} cached event(s) raised by others in '{room.Name}' (ev {code}; {removed} removed in all) - was refused before");
}
break;
}
case Cache.RemoveFromRoomCacheForActorsLeft:
{
// NOT global events. In Photon a global event belongs to the room (it is cached
// as actor 0, which never "leaves"), so this cleanup only drops the per-actor
// AddToRoomCache events of players who are gone. Global events keep their real
// sender here only so replays and RemoveFromRoomCache filters match them.
// This used to delete them too: when a room's creator left, the next master's
// cleanup wiped the BufferedEventHelper "RoomLoad" slices (all global, cache=5)
// and every later joiner got ~3 cached events instead of ~50 and hung on the
// loading screen at 25% (2026-09-26 22:51, room-2-2-741109222: 51 -> 3).
int before = room.Cache.Count;
int globalsKept = room.Cache.Count(e => e.Global && !room.Actors.ContainsKey(e.ActorNr));
int dropped = room.Cache.RemoveAll(e => !e.Global && !room.Actors.ContainsKey(e.ActorNr));
if (dropped > 0 || globalsKept > 0)
Log.Info($"{c.Tag}: '{room.Name}' cleanup for departed players: removed {dropped} of {before} cached event(s), kept {globalsKept} global");
break;
}
case Cache.MergeCache:
{
var existing = me.OwnCache.FirstOrDefault(e => e.Code == code);
if (existing != null && existing.Data is Hashtable eh && data is Hashtable dh) Room.Merge(eh, dh);
else if (existing != null) existing.Data = data;
else if (me.OwnCache.Count < Guard.MaxActorCache) me.OwnCache.Add(new CachedEvent { ActorNr = me.Nr, Code = code, Data = data, Group = group });
break;
}
case Cache.ReplaceCache:
me.OwnCache.RemoveAll(e => e.Code == code);
if (data != null && me.OwnCache.Count < Guard.MaxActorCache) me.OwnCache.Add(new CachedEvent { ActorNr = me.Nr, Code = code, Data = data, Group = group });
break;
case Cache.RemoveCache:
me.OwnCache.RemoveAll(e => e.Code == code); break;
}
if (targets != null)
{
foreach (var nr in targets)
if (room.Actors.TryGetValue(nr, out var a) && a.Conn != null && !a.Inactive) recipients.Add(a.Conn);
}
else if (receivers == Receivers.MasterClient)
{
if (room.Actors.TryGetValue(room.MasterClientId, out var m) && m.Conn != null) recipients.Add(m.Conn);
}
else
{
foreach (var a in room.Actors.Values)
{
if (a.Conn == null || a.Inactive) continue;
if (a == me && receivers != Receivers.All) continue;
if (group != 0 && !a.Groups.Contains(group)) continue;
recipients.Add(a.Conn);
}
}
}
// Cache removals are NEVER delivered, data or not: on a RemoveFromRoomCache the data is the
// FILTER, not a payload (Photon only edits the cache). This used to deliver any removal that
// carried a filter, so every time Rec Room cleared a cached RPC (ev 36) or state event (ev
// 29) everyone in the room received it as a brand-new one and re-ran it - thousands a day.
bool deliver = op.Params.ContainsKey(P.Code) && !(cache >= Cache.RemoveFromRoomCache && cache <= Cache.RemoveFromRoomCacheForActorsLeft);
// Room trace (CAMPUSNET_TRACE_ROOM=<substring of the room name>): every non-transform event
// with its size, routing and - for RPC-shaped payloads - the readable strings inside (RR RPC
// method names are plain text on the wire). Used to see what a "Rooms 2.0" authority
// handshake actually sends, without the firehose of CAMPUSNET_DEBUG.
if (code != 28 && ((TraceRoom.Length > 0 && Traced(room, me)) || (code == 36 && HasUtf16Text(data))))
{
string strs = "";
try
{
var found = new List<string>();
void Scan(object? o, int depth)
{
if (o == null || depth > 4 || found.Count >= 6) return;
if (o is CampusNet.Protocol.CustomValue cvx) o = cvx.Data; // custom types carry their payload as raw bytes (gift packages ride in one)
if (o is string str) { if (str.Length >= 3 && str.Length <= 80) found.Add(str); return; }
if (o is byte[] bytes)
{
var sb = new System.Text.StringBuilder();
foreach (var bt in bytes)
{
if (bt >= 32 && bt < 127) sb.Append((char)bt);
else { if (sb.Length >= 5 && found.Count < 6) found.Add(sb.ToString()); sb.Clear(); }
}
if (sb.Length >= 5 && found.Count < 6) found.Add(sb.ToString());
// UTF-16LE text (a C# string written raw: every other byte 0) reads as
// single characters above and is dropped, which hid typed chat entirely.
var u16 = new System.Text.StringBuilder();
for (int i = 0; i + 1 < bytes.Length; i++)
{
if (bytes[i] >= 32 && bytes[i] < 127 && bytes[i + 1] == 0) { u16.Append((char)bytes[i]); i++; continue; }
if (u16.Length >= 3 && found.Count < 6) found.Add("u16:" + u16);
u16.Clear();
}
if (u16.Length >= 3 && found.Count < 6) found.Add("u16:" + u16);
return;
}
if (o is Hashtable ht) { foreach (DictionaryEntry e in ht) { Scan(e.Key, depth + 1); Scan(e.Value, depth + 1); } return; }
if (o is System.Collections.IEnumerable en) { int n = 0; foreach (var x in en) { if (n++ > 64) break; Scan(x, depth + 1); } }
}
Scan(data, 0);
if (found.Count > 0) strs = " strs=[" + string.Join(" | ", found) + "]";
}
catch { }
// Every op parameter with its decoded CLR type, so a receiver-group / target-list field
// that arrives in an unexpected encoding is visible instead of silently ignored.
string keys = "[" + string.Join(",", op.Params.Select(kv => $"{kv.Key}:{(kv.Value == null ? "null" : kv.Value is TypedArray ta ? $"TypedArray<{(char)ta.ElemType}>" : kv.Value.GetType().Name)}")) + "]";
Log.Info($"trace '{room.Name}' a{me.Nr} u{me.UserId}: ev {code} {op.PayloadBytes}B cache={cache} recv={receivers} group={group} targets=[{(targets == null ? "-" : string.Join(",", targets))}] -> {recipients.Count} recipient(s){(deliver ? "" : " (cache-only)")} keys={keys}{strs}");
}
// Room chat (RPC 36 carrying "<noparse>text</noparse>") is held until the Campus server
// confirms the text went through its chat filter (ChatGuard) - text that skipped it is
// dropped and the sender disconnected. Everything else is delivered as before.
string? chatText = deliver && code == 36 && ChatGuard.Enabled ? ChatGuard.ChatText(data) : null;
if (chatText != null)
{
var msg = GpBinary16.EventMessage(code, EventParams(me.Nr, data));
var to = recipients.ToList();
string user = me.UserId, roomName = room.Name;
_ = Task.Run(async () =>
{
if (await ChatGuard.IsBypass(user, roomName, chatText))
{
Log.Warn($"[chat-guard] u{user} in '{roomName}': unfiltered chat dropped and sender disconnected");
c.Disconnect("chat filter bypass");
return;
}
foreach (var r in to) r.Send(msg, reliable);
});
}
else if (deliver)
{
var msg = GpBinary16.EventMessage(code, EventParams(me.Nr, data));
foreach (var r in recipients) r.Send(msg, reliable);
}
lock (room.Sync)
{
me.Raised++; if (!reliable) me.RaisedUnreliable++;
me.RaisedByCode[code] = me.RaisedByCode.GetValueOrDefault(code) + 1;
if (group != 0) me.RaisedByGroup[group] = me.RaisedByGroup.GetValueOrDefault(group) + 1;
if (deliver) { me.Delivered += recipients.Count; if (recipients.Count == 0) me.DroppedNoRecipient++; }
}
if (reliable) c.SendResponse(op.Code, new()); // PUN expects the ack'd response for reliable raises
}
private static bool CacheFilterMatch(CachedEvent e, int[]? actors, byte? code, Hashtable? dataFilter)
{
if (actors != null && actors.Length > 0 && !actors.Contains(e.ActorNr)) return false;
if (code != null && e.Code != code) return false;
if (dataFilter != null)
{
if (e.Data is not Hashtable h) return false;
foreach (DictionaryEntry f in dataFilter)
if (!h.ContainsKey(f.Key) || !Room.ValuesEqual(h[f.Key], f.Value)) return false;
}
return true;
}
// ---------------------------------------------------------------- properties
private static void SetProperties(Connection c, GpBinary16.OperationRequest op)
{
var room = c.Room; var me = c.Actor;
if (room == null || me == null) { c.SendError(op.Code, Rc.OperationNotAllowedInCurrentState, "Not in a game"); return; }
var props = op.Params.GetValueOrDefault(P.Properties) as Hashtable ?? new Hashtable();
var expected = op.Params.GetValueOrDefault(P.ExpectedValues) as Hashtable;
bool broadcast = op.Params.GetValueOrDefault(P.Broadcast) is bool b && b;
int target = LoadBalancingApp.ToInt(op.Params.GetValueOrDefault(P.ActorNr)); // 0 = room properties
Hashtable changed; List<Connection> recipients = new();
lock (room.Sync)
{
Hashtable store;
if (target == 0) store = room.Props;
else
{
if (!room.Actors.TryGetValue(target, out var a)) { c.SendError(op.Code, Rc.InvalidOperation, "No such actor"); return; }
// A client writes its own player properties; only the master client may write
// another actor's (PUN never does this from a plain client, a modified one would -
// it is how a "bot" renames or re-flags other players).
if (target != me.Nr && me.Nr != room.MasterClientId)
{
Log.Warn($"{c.Tag}: tried to set actor {target}'s properties - refused");
c.SendError(op.Code, Rc.InvalidOperation, "Not your actor");
return;
}
store = a.Props;
}
// Master client hand-off (PUN SetMasterClient): room prop 248 = new master, with the
// CURRENT master as the expected value. 248 is never stored - MasterClientId is live -
// so the plain CAS below always read "stored null" and refused every hand-off, leaving
// game authority (rounds, teams) with whoever had the lowest actor number.
if (target == 0 && props.ContainsKey(GameProp.MasterClientId))
{
int wanted = LoadBalancingApp.ToInt(props[GameProp.MasterClientId]);
int current = room.MasterClientId;
bool casOk = expected == null || !expected.ContainsKey(GameProp.MasterClientId)
|| LoadBalancingApp.ToInt(expected[GameProp.MasterClientId]) == current;
bool valid = room.Actors.TryGetValue(wanted, out var nm) && !nm.Inactive;
if (!casOk || !valid)
{
Log.Warn($"{c.Tag}: master hand-off to a{wanted} refused in '{room.Name}' ({(!valid ? "no such active actor" : $"expected master {GpBinary16.Describe(expected![GameProp.MasterClientId])}, is {current}")})");
c.SendError(op.Code, Rc.InvalidOperation, "CAS failed");
return;
}
props = (Hashtable)props.Clone(); props.Remove(GameProp.MasterClientId);
if (expected != null) { expected = (Hashtable)expected.Clone(); expected.Remove(GameProp.MasterClientId); }
room.MasterOverride = wanted;
Log.Info($"{c.Tag}: master client a{current} -> a{wanted} in '{room.Name}'");
// Everyone (the sender included) hears it, as Photon does: PUN switches master off
// the 248 PropertiesChanged event.
var mev = new Dictionary<byte, object?> { [P.TargetActorNr] = 0, [P.Properties] = new Hashtable { [GameProp.MasterClientId] = wanted }, [P.ActorNr] = me.Nr };
var mmsg = GpBinary16.EventMessage(Ev.PropertiesChanged, mev);
foreach (var a in room.Actors.Values) if (a.Conn != null && !a.Inactive) a.Conn.Send(mmsg);
if (props.Count == 0) { c.SendResponse(op.Code, new()); return; }
}
if (!Room.CasMatches(store, expected))
{
// Always logged: a refused compare-and-set silently drops that client's state change
// for everyone (it is how per-player objects - chat, typing, held items - sync).
var why = string.Join(", ", expected!.Keys.Cast<object>().Select(k => $"{k}: expected {GpBinary16.Describe(expected[k])} stored {GpBinary16.Describe(store[k])}"));
Log.Warn($"{c.Tag}: CAS refused on {(target == 0 ? "room" : "actor " + target)} in '{room.Name}' - {Trunc(why, 400)} | wanted [{Trunc(string.Join(", ", props.Keys.Cast<object>()), 200)}]");
c.SendError(op.Code, Rc.InvalidOperation, "CAS failed"); // PUN treats this rc as the CAS miss
return;
}
changed = Room.Merge(store, props);
if (target == 0 && changed.ContainsKey(GameProp.PlayerTtl) && changed[GameProp.PlayerTtl] is int pt) room.PlayerTtl = pt;
if (target == 0 && changed.ContainsKey(GameProp.EmptyRoomTtl) && changed[GameProp.EmptyRoomTtl] is int et) room.EmptyRoomTtl = et;
foreach (var a in room.Actors.Values)
if (a.Conn != null && !a.Inactive && (broadcast || a.Conn != c)) recipients.Add(a.Conn);
}
c.SendResponse(op.Code, new());
// Player-property writes are rare (join, avatar/state changes) - log the keys and sizes so a
// "others don't see my new outfit" report can be matched against what actually crossed the wire.
bool chatKeys = changed.Keys.Cast<object>().Any(k => k is string ks && (ks.Contains("TYPING") || ks.Contains("CHAT", StringComparison.OrdinalIgnoreCase)));
if (target != 0 || chatKeys || (TraceRoom.Length > 0 && Traced(room, me)))
{
var desc = string.Join(", ", changed.Keys.Cast<object>().Select(k => $"{k}={(changed[k] is byte[] ba ? ba.Length + "B" : changed[k] is string str ? str.Length + "ch" : changed[k] is Hashtable ? "table" : changed[k] is object?[] arr ? Trunc(GpBinary16.Describe(arr), 240) : changed[k]?.ToString() ?? "null")}"));
Log.Info($"{c.Tag}: {(target == 0 ? "room" : "player")} props on {(target == 0 ? "room" : "actor " + target)} [{desc}] cas={(expected != null)} -> {recipients.Count} recipient(s){(broadcast ? " (broadcast)" : "")}");
}
if (broadcast || recipients.Count > 0)
{
var ev = new Dictionary<byte, object?> { [P.TargetActorNr] = target, [P.Properties] = changed, [P.ActorNr] = me.Nr };
var msg = GpBinary16.EventMessage(Ev.PropertiesChanged, ev);
foreach (var r in recipients) if (broadcast || r != c) r.Send(msg);
}
}
private static void GetProperties(Connection c, GpBinary16.OperationRequest op)
{
var room = c.Room;
if (room == null) { c.SendError(op.Code, Rc.OperationNotAllowedInCurrentState, "Not in a game"); return; }
var resp = new Dictionary<byte, object?>();
lock (room.Sync)
{
resp[P.GameProperties] = room.GamePropsForClient();
resp[P.PlayerProperties] = room.AllPlayerProps();
}
c.SendResponse(op.Code, resp);
}
private static void ChangeGroups(Connection c, GpBinary16.OperationRequest op)
{
var me = c.Actor; var room = c.Room;
if (room == null || me == null) { c.SendError(op.Code, Rc.OperationNotAllowedInCurrentState, "Not in a game"); return; }
lock (room.Sync)
{
// null = all groups; empty array = none
if (op.Params.TryGetValue(P.Remove, out var rem))
{
if (rem == null) me.Groups.Clear();
else foreach (var g in AsBytes(rem)) me.Groups.Remove(g);
}
if (op.Params.TryGetValue(P.Add, out var add))
{
if (add == null) for (int g = 1; g < 256; g++) me.Groups.Add((byte)g);
else foreach (var g in AsBytes(add)) me.Groups.Add(g);
}
Log.Info($"{c.Tag}: groups now [{(me.Groups.Count > 12 ? me.Groups.Count + " groups" : string.Join(",", me.Groups.OrderBy(g => g)))}] (remove={(op.Params.TryGetValue(P.Remove, out var rr) ? (rr == null ? "all" : GpBinary16.Describe(rr)) : "-")} add={(op.Params.TryGetValue(P.Add, out var aa) ? (aa == null ? "all" : GpBinary16.Describe(aa)) : "-")})");
}
c.SendResponse(op.Code, new());
}
private static IEnumerable<byte> AsBytes(object v) => v switch
{
byte[] b => b,
TypedArray ta => ta.Items.Select(x => (byte)LoadBalancingApp.ToInt(x)),
int[] n => n.Select(x => (byte)x),
_ => Array.Empty<byte>()
};
}
@@ -0,0 +1,161 @@
using System.Collections;
using CampusNet.Protocol;
namespace CampusNet.Services;
/// <summary>
/// Lets everyone in the room see a gift box's message and box type, not only its owner.
///
/// A box someone opens in a shared room is spawned over Photon (ev 29, prefab "(GiftBox)") with
/// its gift package in the spawn data as UTF-8 JSON. Every OTHER client builds the box from that
/// JSON in GiftBox.Awake and only fills it in (message, name, box variant) when the package's
/// IsSignatureValid is true - otherwise it logs "Bad signature for remote gift package ID" and
/// draws a plain unlabelled box. The owner's client sends the flag as false (it re-checks the
/// signature against Rec Room's own key, which a Campus-signed package can never pass), even
/// though the Campus server hands the package out with IsSignatureValid true.
///
/// So the flag is set on the way through: "IsSignatureValid":false becomes
/// "IsSignatureValid":true - the same length (JSON allows the trailing space), so the length
/// prefixes of the custom-type blob around it stay valid and nothing is re-encoded.
/// CAMPUSNET_GIFTBOX_VALID=0 turns it off.
/// </summary>
public static class GiftBoxRelay
{
public static readonly bool Enabled = Environment.GetEnvironmentVariable("CAMPUSNET_GIFTBOX_VALID") != "0";
private static readonly byte[][] From =
{
"\"IsSignatureValid\":false"u8.ToArray(),
"\"isSignatureValid\":false"u8.ToArray(),
"\"issignaturevalid\":false"u8.ToArray(),
};
private static readonly byte[][] To =
{
"\"IsSignatureValid\":true "u8.ToArray(),
"\"isSignatureValid\":true "u8.ToArray(),
"\"issignaturevalid\":true "u8.ToArray(),
};
// ---- capture (diagnostics) ------------------------------------------------------------
// The first few gift-box spawns are saved raw (how the string-typed package was found).
// CAMPUSNET_GIFTBOX_CAPTURE=1 turns it on.
private static readonly bool CaptureOn = Environment.GetEnvironmentVariable("CAMPUSNET_GIFTBOX_CAPTURE") == "1"; // off unless asked for
private static int _captured;
private const int MaxCaptures = 10;
public static void Capture(object? data, string room, string? userId)
{
if (!CaptureOn || Interlocked.Increment(ref _captured) > MaxCaptures) return;
try
{
string dir = Path.Combine(AppContext.BaseDirectory, "giftbox-captures");
Directory.CreateDirectory(dir);
string stem = Path.Combine(dir, $"{DateTime.UtcNow:yyyyMMdd-HHmmss-fff}_u{userId}");
var w = new GpBinary16.Writer(2048);
w.Value(data);
File.WriteAllBytes(stem + ".bin", w.ToArray());
var sb = new System.Text.StringBuilder();
sb.AppendLine($"room {room} user {userId} bytes {w.Length}");
void Dump(object? o, string path, int depth)
{
if (depth > 8) return;
switch (o)
{
case null: sb.AppendLine($"{path} = null"); break;
case byte[] b: sb.AppendLine($"{path} = byte[{b.Length}] {Convert.ToHexString(b.AsSpan(0, Math.Min(b.Length, 4096)))}"); break;
case CustomValue cv: sb.AppendLine($"{path} = custom '{(char)cv.Code}' ({cv.Code}) [{cv.Data.Length}] {Convert.ToHexString(cv.Data.AsSpan(0, Math.Min(cv.Data.Length, 4096)))}"); break;
case Hashtable ht: foreach (DictionaryEntry e in ht) Dump(e.Value, $"{path}{{{e.Key} ({e.Key?.GetType().Name})}}", depth + 1); break;
case TypedArray ta: sb.AppendLine($"{path} = typed '{(char)ta.ElemType}'[{ta.Items.Length}] custom={ta.CustomCode}"); for (int i = 0; i < ta.Items.Length; i++) Dump(ta.Items[i], $"{path}[{i}]", depth + 1); break;
case object?[] arr: sb.AppendLine($"{path} = object[{arr.Length}]"); for (int i = 0; i < arr.Length; i++) Dump(arr[i], $"{path}[{i}]", depth + 1); break;
default: sb.AppendLine($"{path} = {o.GetType().Name} {o}"); break;
}
}
Dump(data, "data", 0);
File.WriteAllText(stem + ".txt", sb.ToString());
Log.Info($"[giftbox-capture] saved {stem}.bin ({w.Length} bytes) from u{userId} in '{room}'");
}
catch (Exception ex) { Log.Warn($"[giftbox-capture] failed: {ex.Message}"); }
}
/// <summary>Flip the flag everywhere inside this event's data. Returns how many were changed.</summary>
public static int MarkValid(object? data)
{
if (!Enabled) return 0;
int n = 0;
Walk(data, 0, ref n);
return n;
}
// The package rides as a STRING: ev 29 data {6: object[] { -1, TypedDictionary<int,object> {
// 34: "{\"Id\":106133,...,\"IsSignatureValid\":false,...}" } } } (captured 2026-09-27). Strings
// are immutable, so a string value is swapped for its patched copy inside its container.
private static void Walk(object? o, int depth, ref int n)
{
if (o == null || depth > 6) return;
switch (o)
{
case byte[] b: n += Patch(b); return;
case CustomValue cv: n += Patch(cv.Data); return;
case Hashtable ht:
foreach (var key in ht.Keys.Cast<object>().ToList())
{
if (ht[key] is string sv) { var f = Fix(sv); if (f != null) { ht[key] = f; n++; } }
else Walk(ht[key], depth + 1, ref n);
}
return;
case TypedDictionary td:
for (int i = 0; i < td.Entries.Count; i++)
{
var e = td.Entries[i];
if (e.Value is string sv) { var f = Fix(sv); if (f != null) { td.Entries[i] = new KeyValuePair<object?, object?>(e.Key, f); n++; } }
else Walk(e.Value, depth + 1, ref n);
}
return;
case TypedArray ta:
for (int i = 0; i < ta.Items.Length; i++)
{
if (ta.Items[i] is string sv) { var f = Fix(sv); if (f != null) { ta.Items[i] = f; n++; } }
else Walk(ta.Items[i], depth + 1, ref n);
}
return;
case object?[] arr:
for (int i = 0; i < arr.Length; i++)
{
if (arr[i] is string sv) { var f = Fix(sv); if (f != null) { arr[i] = f; n++; } }
else Walk(arr[i], depth + 1, ref n);
}
return;
case string: return; // a top-level string cannot be swapped; containers handle theirs
case IEnumerable en: foreach (var x in en) Walk(x, depth + 1, ref n); return;
}
}
private static readonly string[] FromS = { "\"IsSignatureValid\":false", "\"isSignatureValid\":false", "\"issignaturevalid\":false" };
private static readonly string[] ToS = { "\"IsSignatureValid\":true", "\"isSignatureValid\":true", "\"issignaturevalid\":true" };
/// <summary>The string with the flag set, or null when it has none to set.</summary>
private static string? Fix(string s)
{
if (s.Length < 20 || s.IndexOf("ignatureValid", StringComparison.OrdinalIgnoreCase) < 0) return null;
string r = s;
for (int i = 0; i < FromS.Length; i++) r = r.Replace(FromS[i], ToS[i], StringComparison.Ordinal);
return ReferenceEquals(r, s) || r == s ? null : r;
}
private static int Patch(byte[] b)
{
int n = 0;
for (int p = 0; p < From.Length; p++)
{
var f = From[p];
int i = 0;
while ((i = b.AsSpan(i).IndexOf(f) is var k && k >= 0 ? i + k : -1) >= 0)
{
To[p].CopyTo(b, i);
i += f.Length;
n++;
}
}
return n;
}
}
@@ -0,0 +1,55 @@
namespace CampusNet.Services;
/// <summary>
/// Keeps a kicked player out of the Photon room they were kicked from. The Campus server records a
/// block per instance (InstanceBlocks) and its matchmaking refuses them - but a kicked / warned
/// client reconnects to its old Photon room by itself, straight through the master, without asking
/// the API, so it walked right back in (2026-09-27). The list of blocks in force is pulled from the
/// Campus server (localhost only) every couple of seconds and a join into a blocked room is refused
/// with GameClosed, which the client handles by falling back to its dorm.
/// CAMPUSNET_ENFORCE_KICKS=0 turns it off.
/// </summary>
public static class JoinBlocks
{
private static readonly string Url = Environment.GetEnvironmentVariable("CAMPUSNET_JOINBLOCKS_URL") ?? "http://127.0.0.1:8080/photon/joinblocks";
public static readonly bool Enabled = Environment.GetEnvironmentVariable("CAMPUSNET_ENFORCE_KICKS") != "0";
private static readonly HttpClient Http = new() { Timeout = TimeSpan.FromSeconds(3) };
private static volatile HashSet<string> _blocked = new(StringComparer.Ordinal);
private static int _started;
private static string Key(string userId, string room) => userId + "\n" + room;
public static void Start()
{
if (!Enabled || Interlocked.Exchange(ref _started, 1) == 1) return;
_ = Task.Run(async () =>
{
bool warned = false;
while (true)
{
try
{
string text = await Http.GetStringAsync(Url);
var set = new HashSet<string>(StringComparer.Ordinal);
foreach (var line in text.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
int sp = line.IndexOf(' ');
if (sp > 0) set.Add(Key(line[..sp], line[(sp + 1)..]));
}
_blocked = set;
warned = false;
}
catch (Exception ex)
{
// Keep the last list: a Campus restart must not open the doors.
if (!warned) { warned = true; Log.Warn($"[joinblocks] could not fetch kick blocks: {ex.Message}"); }
}
await Task.Delay(2000);
}
});
}
/// <summary>Is this player kicked from this Photon room right now?</summary>
public static bool IsBlocked(string? userId, string room) =>
Enabled && !string.IsNullOrEmpty(userId) && _blocked.Contains(Key(userId, room));
}
@@ -0,0 +1,36 @@
namespace CampusNet.Services;
/// <summary>
/// Any logged-in client can join a Photon room by NAME - nothing checks that the Campus server
/// actually put them there, so a player who learns a room name can walk into a private room or
/// dorm past invites, room bans and the junior filter. Enforcing that blind could lock people out
/// of legitimate joins (event broadcasts, reconnects, party follow), so this first runs in LOG-ONLY
/// mode: after each join it asks the Campus server (localhost only) which Photon room the player was
/// matchmade into and warns on a mismatch. Once the log shows no false alarms, set
/// CAMPUSNET_ENFORCE_JOINS=1 to refuse them.
/// </summary>
public static class JoinCheck
{
private static readonly string Url = Environment.GetEnvironmentVariable("CAMPUSNET_PLACEMENT_URL") ?? "http://127.0.0.1:8080/photon/placement";
public static readonly bool Enforce = Environment.GetEnvironmentVariable("CAMPUSNET_ENFORCE_JOINS") == "1";
private static readonly HttpClient Http = new() { Timeout = TimeSpan.FromSeconds(2) };
/// <summary>The Photon room the Campus server placed this player in ("" = none known, null = lookup failed).</summary>
public static async Task<string?> PlacementOf(string userId)
{
try { return (await Http.GetStringAsync($"{Url}?id={Uri.EscapeDataString(userId)}")).Trim(); }
catch { return null; }
}
public static void Observe(string? userId, string room, string tag)
{
if (string.IsNullOrEmpty(userId) || !ulong.TryParse(userId, out _)) return;
_ = Task.Run(async () =>
{
var placed = await PlacementOf(userId);
if (placed == null) return; // Campus unreachable: say nothing rather than cry wolf
if (!string.Equals(placed, room, StringComparison.Ordinal))
Log.Warn($"[joincheck] {tag} u{userId} joined '{room}' but Campus placed them in '{(placed.Length == 0 ? "(nowhere)" : placed)}'{(Enforce ? "" : " (log only)")}");
});
}
}
@@ -0,0 +1,304 @@
using System.Collections;
using CampusNet.Net;
using CampusNet.Protocol;
namespace CampusNet.Services;
public enum Role { NameServer, Master, Game }
/// <summary>
/// The Photon LoadBalancing application: one instance per role/port. The client walks
/// NameServer (GetRegions/Authenticate → master address) → Master (Authenticate, JoinGame → game
/// address) → Game (Authenticate, JoinGame, RaiseEvent/SetProperties/Leave). All three run in this
/// process and share the room registry, so "addresses" are just this host's ports.
/// </summary>
public sealed class LoadBalancingApp
{
public readonly Role Role;
public readonly PhotonUdpServer Server;
public readonly string PublicHost;
public readonly int MasterPort, GamePort;
private static readonly object AppStatsSync = new();
public LoadBalancingApp(Role role, PhotonUdpServer server, string publicHost, int masterPort, int gamePort)
{
Role = role; Server = server; PublicHost = publicHost; MasterPort = masterPort; GamePort = gamePort;
server.OnConnect += p => p.Session = new Connection(server, p);
server.OnMessage += OnMessage;
server.OnDisconnect += OnDisconnect;
}
private static Connection C(PhotonUdpServer.Peer p) => (Connection)(p.Session ??= null!);
private void OnMessage(PhotonUdpServer.Peer peer, byte[] message, bool reliable)
{
if (peer.Session is not Connection c) { c = new Connection(Server, peer); peer.Session = c; }
GpBinary16.OperationRequest? op;
try { op = c.Decode(message, out _); }
catch (Exception ex) { Log.Warn($"{c.Tag}: decode failed: {ex.Message} [{Convert.ToHexString(message.AsSpan(0, Math.Min(48, message.Length)))}]"); return; }
if (op == null) return;
if (Log.Debug) Log.Dbg($"{c.Tag} <- op {op.Code} {DescribeParams(op.Params)}");
try { Handle(c, op, reliable); }
catch (Exception ex)
{
Log.Warn($"{c.Tag}: op {op.Code} failed: {ex}");
c.SendError(op.Code, Rc.InternalServerError, ex.Message);
}
}
private void OnDisconnect(PhotonUdpServer.Peer peer)
{
if (peer.Session is not Connection c) return;
if (Role == Role.Game && c.Room != null) GameService.OnConnectionLost(c);
if (Role == Role.Master && c.Proto18) ChatService.OnDisconnect(c);
}
private void Handle(Connection c, GpBinary16.OperationRequest op, bool reliable)
{
switch (op.Code)
{
case Op.GetRegions: HandleGetRegions(c, op); return;
case Op.Authenticate:
case Op.AuthenticateOnce: HandleAuthenticate(c, op); return;
}
if (c.UserId == null) { c.SendError(op.Code, Rc.OperationNotAllowedInCurrentState, "Not authenticated"); return; }
if (Role != Role.Game && !c.Charge(0, $"op {op.Code}")) return;
switch (Role)
{
case Role.Master: HandleMaster(c, op); return;
case Role.Game: GameService.Handle(c, op, reliable); return;
default: c.SendError(op.Code, Rc.InvalidOperation, "Not on this server"); return;
}
}
// ---------------------------------------------------------------- name server
private void HandleGetRegions(Connection c, GpBinary16.OperationRequest op)
{
c.SendResponse(Op.GetRegions, new Dictionary<byte, object?>
{
[P.Region] = new[] { "us" },
[P.Address] = new[] { $"{PublicHost}:{MasterPort}" }
});
}
private void HandleAuthenticate(Connection c, GpBinary16.OperationRequest op)
{
string? userId = null, nick = "";
if (Role == Role.NameServer)
{
// Custom auth: the Campus photonAuthToken rides in ClientAuthenticationData (216); a
// few builds put it in the params string (214) instead. Fall back to the UserId (225).
// Rec Room sends the JWT as the BYTES of ClientAuthenticationParams (214), not a string.
string? token = null;
foreach (var key in new[] { P.ClientAuthenticationData, P.ClientAuthenticationParams })
{
var raw = op.Params.GetValueOrDefault(key);
var str = raw as string ?? (raw is byte[] rb ? System.Text.Encoding.UTF8.GetString(rb) : null);
if (string.IsNullOrEmpty(str)) continue;
if (str.StartsWith("token=")) str = str[6..];
// Rec Room's auth data is JSON: {"accountId":"6","accessToken":"<the game session JWT>"}.
if (str.StartsWith("{"))
{
try
{
using var doc = System.Text.Json.JsonDocument.Parse(str);
if (doc.RootElement.TryGetProperty("accessToken", out var at)) str = at.GetString() ?? str;
}
catch { }
}
if (Tokens.ValidateCampusJwt(str) != null) { token = str; break; }
token ??= str;
}
var v = Tokens.ValidateCampusJwt(token);
if (v != null) userId = v.Value.userId;
else if (op.Params.GetValueOrDefault(P.UserId) is string uid && uid.Length > 0 && AllowUnsignedUserId) userId = uid;
if (userId == null)
{
Log.Warn($"{c.Tag}: authentication rejected (token {(token == null ? "absent" : "invalid")}) params={DescribeParams(op.Params)} token='{token}'");
if (op.Params.GetValueOrDefault(P.ClientAuthenticationParams) is byte[] rawp) Log.Warn($"{c.Tag}: 214 hex={Convert.ToHexString(rawp.AsSpan(0, Math.Min(64, rawp.Length)))}");
c.SendError(op.Code, Rc.CustomAuthenticationFailed, "Invalid token");
return;
}
nick = op.Params.GetValueOrDefault(P.UriPath) as string ?? "";
}
else
{
var s = Tokens.ValidateSecret(op.Params.GetValueOrDefault(P.Secret) as string);
if (s == null)
{
Log.Warn($"{c.Tag}: bad secret on {Role}");
c.SendError(op.Code, Rc.InvalidAuthentication, "Invalid secret");
return;
}
userId = s.Value.userId; nick = s.Value.nick;
}
c.UserId = userId; c.NickName = nick ?? "";
string secret = Tokens.MintSecret(userId, c.NickName);
var resp = new Dictionary<byte, object?> { [P.Secret] = secret, [P.UserId] = userId };
if (Role == Role.NameServer) resp[P.Address] = $"{PublicHost}:{MasterPort}";
c.SendResponse(op.Code, resp);
Log.Info($"{c.Tag}: authenticated on {Role} (app '{c.AppName}')");
if (Role == Role.Master && !c.Proto18) SendAppStats(c); // a LoadBalancing event; a chat client has no use for it
}
public static bool AllowUnsignedUserId = Environment.GetEnvironmentVariable("CAMPUSNET_ALLOW_UNSIGNED") == "1";
// ---------------------------------------------------------------- master
private void SendAppStats(Connection c)
{
int peers = Server.PeerCount;
c.SendEvent(Ev.AppStats, new Dictionary<byte, object?>
{
[P.MasterPeerCount] = peers,
[P.PeerCount] = Rooms.All().Sum(r => r.ActiveCount),
[P.GameCount] = Rooms.Count
}, reliable: false);
}
private void HandleMaster(Connection c, GpBinary16.OperationRequest op)
{
// Protocol-1.8 connections here are the client's Photon Chat library (event broadcasting -
// see ChatService). Chat's op codes (0-9) never collide with LoadBalancing's (217+).
if (c.Proto18 && ChatOp.Is(op.Code)) { ChatService.Handle(c, op); return; }
if (c.Proto18) Log.Info($"{c.Tag}: 1.8 op {op.Code} {DescribeParams(op.Params)}");
switch (op.Code)
{
case Op.JoinLobby:
c.InLobby = true;
c.SendResponse(op.Code, new());
c.SendEvent(Ev.GameList, new Dictionary<byte, object?> { [P.GameList] = GameListHashtable() });
return;
case Op.LeaveLobby:
c.InLobby = false;
c.SendResponse(op.Code, new());
return;
case Op.CreateGame:
case Op.JoinGame:
{
string name = op.Params.GetValueOrDefault(P.RoomName) as string ?? "";
if (name.Length == 0) name = Guid.NewGuid().ToString("N");
int joinMode = ToInt(op.Params.GetValueOrDefault(P.JoinMode));
var room = Rooms.Get(name);
if (op.Code == Op.JoinGame && room == null && joinMode == 0)
{
c.SendError(op.Code, Rc.GameDoesNotExist, "Game does not exist"); return;
}
if (JoinBlocks.IsBlocked(c.UserId, name))
{
Log.Info($"{c.Tag}: refused join to '{name}' - kicked from that instance");
c.SendError(op.Code, Rc.GameClosed, "Game closed"); return;
}
if (op.Code == Op.CreateGame && room != null && joinMode == 0)
{
c.SendError(op.Code, Rc.GameIdAlreadyExists, "A game with the specified id already exist"); return;
}
if (room != null)
{
lock (room.Sync)
{
bool rejoin = room.FindByUserId(c.UserId!) != null;
if (!rejoin && !room.IsOpen) { c.SendError(op.Code, Rc.GameClosed, "Game closed"); return; }
if (!rejoin && room.MaxPlayers > 0 && room.ActiveCount >= room.MaxPlayers) { c.SendError(op.Code, Rc.GameFull, "Game full"); return; }
}
}
c.SendResponse(op.Code, new Dictionary<byte, object?>
{
[P.Address] = $"{PublicHost}:{GamePort}",
[P.Secret] = Tokens.MintSecret(c.UserId!, c.NickName),
[P.RoomName] = name
});
Log.Info($"{c.Tag}: {(op.Code == Op.CreateGame ? "create" : "join")} '{name}' -> game server");
return;
}
case Op.JoinRandomGame:
{
var expected = op.Params.GetValueOrDefault(P.GameProperties) as Hashtable;
Room? pick = null;
foreach (var r in Rooms.All())
{
lock (r.Sync)
{
if (!r.IsOpen || !r.IsVisible) continue;
if (r.MaxPlayers > 0 && r.ActiveCount >= r.MaxPlayers) continue;
if (expected != null && !Room.CasMatches(r.Props, expected)) continue;
pick = r; break;
}
}
if (pick == null) { c.SendError(op.Code, Rc.NoRandomMatchFound, "No match found"); return; }
c.SendResponse(op.Code, new Dictionary<byte, object?>
{
[P.Address] = $"{PublicHost}:{GamePort}",
[P.Secret] = Tokens.MintSecret(c.UserId!, c.NickName),
[P.RoomName] = pick.Name
});
return;
}
case Op.GetGameList:
c.SendResponse(op.Code, new Dictionary<byte, object?> { [P.GameList] = GameListHashtable() });
return;
case Op.FindFriends:
{
// Answers "not online, no room" for everyone. It used to return the exact Photon room
// name of ANY player id asked about - private rooms and dorms included - and any
// logged-in client may join a room by name, so it was a way into anyone's private
// room past invites and room bans. Rec Room finds friends through RecNet presence,
// which honours privacy, not through this.
var ids = op.Params.GetValueOrDefault((byte)1) as string[] ?? Array.Empty<string>();
var online = new bool[ids.Length]; var rooms = new string[ids.Length];
for (int i = 0; i < ids.Length; i++) rooms[i] = "";
c.SendResponse(op.Code, new Dictionary<byte, object?>
{
[1] = new TypedArray((byte)'o', online.Cast<object?>().ToArray()),
[2] = rooms
});
return;
}
case Op.GetLobbyStats:
c.SendResponse(op.Code, new Dictionary<byte, object?>
{
[P.LobbyName] = new[] { "" }, [P.LobbyType] = new TypedArray((byte)'b', new object?[] { (byte)0 }),
[P.PeerCount] = new[] { Server.PeerCount }, [P.GameCount] = new[] { Rooms.Count }
});
return;
case Op.SetProperties:
case Op.RaiseEvent:
c.SendResponse(op.Code, new()); // harmless on the master
return;
case Op.ServerSettings:
c.SendResponse(op.Code, new());
return;
default:
Log.Warn($"{c.Tag}: master op {op.Code} not implemented {DescribeParams(op.Params)}");
c.SendError(op.Code, Rc.InvalidOperation, "Operation not supported");
return;
}
}
private static Hashtable GameListHashtable()
{
var h = new Hashtable();
foreach (var r in Rooms.All())
{
lock (r.Sync)
{
if (!r.IsVisible) continue;
var props = new Hashtable();
if (r.Props[GameProp.PropsListedInLobby] is string[] listed)
foreach (var k in listed) if (r.Props.ContainsKey(k)) props[k] = r.Props[k];
props[GameProp.PlayerCount] = (byte)r.ActiveCount;
props[GameProp.MaxPlayers] = (byte)Math.Clamp(r.MaxPlayers, 0, 255);
props[GameProp.IsOpen] = r.IsOpen;
props[GameProp.IsVisible] = r.IsVisible;
h[r.Name] = props;
}
}
return h;
}
public static int ToInt(object? v) => v switch { null => 0, byte b => b, short s => s, int i => i, long l => (int)l, bool o => o ? 1 : 0, _ => 0 };
public static string DescribeParams(IReadOnlyDictionary<byte, object?> p)
=> "{" + string.Join(", ", p.Select(e => $"{e.Key}={GpBinary16.Describe(e.Value)}")) + "}";
}
+171
View File
@@ -0,0 +1,171 @@
using System.Collections;
using CampusNet.Protocol;
namespace CampusNet.Services;
public sealed class Actor
{
public int Nr;
public string UserId = "";
public Connection? Conn; // null while inactive (disconnected with PlayerTtl)
public readonly Hashtable Props = new();
public bool Inactive;
public DateTime InactiveSince;
public readonly HashSet<byte> Groups = new(); // interest groups (0 = always)
public readonly List<CachedEvent> OwnCache = new(); // MergeCache / ReplaceCache per actor (legacy)
// Traffic counters for the periodic room summary (see GameService.Sweep): what this actor
// raised, how much of it was unreliable, per event code, and how many recipients it reached.
public long Raised, RaisedUnreliable, Delivered, DroppedNoRecipient;
public readonly Dictionary<byte, int> RaisedByCode = new();
public readonly Dictionary<byte, int> RaisedByGroup = new();
// Stuck-load watchdog (GameService.Sweep / StuckLoad): when this actor last became active,
// whether it has shown it finished loading (streamed a transform, ev 28, or sent its request
// to the master client), and whether it has already been reported as stuck.
public DateTime ActiveSince = DateTime.UtcNow;
public bool LoadSignal, StuckReported, CreatedRoom;
}
public sealed class CachedEvent
{
// ActorNr is the REAL sender, also for AddToRoomCacheGlobal: Photon keeps the sender on a
// global event (it is replayed as coming from them and a RemoveFromRoomCache actor filter
// matches it); "global" only means it survives its sender leaving. This used to store 0
// for global events, so non-master removals never matched them (stale game state piled up
// and was replayed to late joiners) and replays arrived from actor 0.
public int ActorNr; public byte Code; public object? Data; public byte Group;
public bool Global;
}
/// <summary>A game room: actors, properties, and the event cache PUN relies on for late joiners.</summary>
public sealed class Room
{
public readonly string Name;
public readonly Hashtable Props = new(); // byte system keys + custom keys
public readonly SortedDictionary<int, Actor> Actors = new();
public readonly List<CachedEvent> Cache = new(); // AddToRoomCache / AddToRoomCacheGlobal, in order
public int NextActorNr = 1;
public int PlayerTtl, EmptyRoomTtl;
// OFF unless the creating client asks for it. PUN sends op param 241 / game prop 249 ONLY when
// RoomOptions.CleanupCacheOnLeave is true, so their absence means the client wants the cache
// KEPT - and Rec Room's rooms are created without them ({"BUG":1, 254:False, 250:[], 253:True}).
// This used to default to true, which deleted the room CREATOR's cached events when they left:
// those hold the room's buffered state (BufferedEventHelper "RoomLoad" slices), so every later
// joiner got 3 cached events instead of ~40 and hung on the loading screen at 25% forever
// ("Waiting to receive buffered message"; seen 2026-09-26 in Campus-Dorm-404635683). Rec Room
// clears departed players' events itself (RemoveFromRoomCacheForActorsLeft) once it has moved on.
public bool CleanupCacheOnLeave = false;
public bool LoggedForeignRemove;
public bool LoggedGiftBoxValid; // first gift-box spawn marked valid (GiftBoxRelay), logged once
public bool LoggedGiftBoxSkip; // first uncached gift-box spawn, logged once // first non-master removal of others' events, logged once
public DateTime EmptySince = DateTime.UtcNow;
public readonly object Sync = new();
public readonly DateTime CreatedAt = DateTime.UtcNow;
public Room(string name) { Name = name; }
public int MaxPlayers => Props[GameProp.MaxPlayers] is byte b ? b : (Props[GameProp.MaxPlayers] is int i ? i : 0);
public bool IsOpen => Props[GameProp.IsOpen] is bool o ? o : true;
public bool IsVisible => Props[GameProp.IsVisible] is bool v ? v : true;
public int ActiveCount => Actors.Values.Count(a => !a.Inactive);
// Photon's SetMasterClient: a room-property write of 248 guarded by the current master. The
// chosen actor stays master while active; otherwise the lowest active actor number is.
public int MasterOverride;
public int MasterClientId => MasterOverride > 0 && Actors.TryGetValue(MasterOverride, out var mo) && !mo.Inactive
? MasterOverride
: Actors.Values.Where(a => !a.Inactive).Select(a => a.Nr).DefaultIfEmpty(0).Min();
public Actor? FindByUserId(string userId) => Actors.Values.FirstOrDefault(a => a.UserId == userId);
/// <summary>Game properties as sent to clients: stored props plus the live system values.</summary>
public Hashtable GamePropsForClient()
{
var h = new Hashtable();
foreach (DictionaryEntry e in Props) h[e.Key] = e.Value;
h[GameProp.MasterClientId] = MasterClientId;
h[GameProp.PlayerCount] = (byte)ActiveCount;
if (!h.ContainsKey(GameProp.IsOpen)) h[GameProp.IsOpen] = true;
if (!h.ContainsKey(GameProp.IsVisible)) h[GameProp.IsVisible] = true;
return h;
}
/// <summary>All players' properties keyed by actor number (what the join response carries).</summary>
public Hashtable AllPlayerProps()
{
var h = new Hashtable();
foreach (var a in Actors.Values) h[a.Nr] = PlayerPropsForClient(a);
return h;
}
public static Hashtable PlayerPropsForClient(Actor a)
{
var h = new Hashtable();
foreach (DictionaryEntry e in a.Props) h[e.Key] = e.Value;
h[PlayerProp.UserId] = a.UserId;
h[PlayerProp.IsInactive] = a.Inactive;
if (!h.ContainsKey(PlayerProp.NickName)) h[PlayerProp.NickName] = "";
return h;
}
public int[] ActorList() => Actors.Values.Where(a => !a.Inactive).Select(a => a.Nr).ToArray();
/// <summary>Copy props from a client hashtable into a store, honouring the "null removes" rule; returns the changed subset.</summary>
public static Hashtable Merge(Hashtable target, Hashtable incoming)
{
var changed = new Hashtable();
foreach (DictionaryEntry e in incoming)
{
if (e.Value == null) { if (target.ContainsKey(e.Key)) { target.Remove(e.Key); changed[e.Key] = null; } }
else { target[e.Key] = e.Value; changed[e.Key] = e.Value; }
}
return changed;
}
/// <summary>Check-and-set: every expected value must match the current one (both absent counts as match).</summary>
public static bool CasMatches(Hashtable target, Hashtable? expected)
{
if (expected == null) return true;
foreach (DictionaryEntry e in expected)
{
var cur = target[e.Key];
if (!ValuesEqual(cur, e.Value)) return false;
}
return true;
}
public static bool ValuesEqual(object? a, object? b)
{
if (a == null || b == null) return a == null && b == null;
if (a is byte[] ab && b is byte[] bb) return ab.AsSpan().SequenceEqual(bb);
if (a is Array aa && b is Array ba)
{
if (aa.Length != ba.Length) return false;
for (int i = 0; i < aa.Length; i++) if (!ValuesEqual(aa.GetValue(i), ba.GetValue(i))) return false;
return true;
}
if (a is CustomValue ca && b is CustomValue cb) return ca.Code == cb.Code && ca.Data.AsSpan().SequenceEqual(cb.Data);
try { return Convert.ToDouble(a) == Convert.ToDouble(b); } catch { }
return a.Equals(b);
}
}
/// <summary>Every room this process hosts (master and game share it - one process).</summary>
public static class Rooms
{
private static readonly Dictionary<string, Room> _rooms = new();
public static readonly object Sync = new();
public static Room? Get(string name) { lock (Sync) return _rooms.TryGetValue(name, out var r) ? r : null; }
public static Room GetOrCreate(string name, out bool created)
{
lock (Sync)
{
if (_rooms.TryGetValue(name, out var r)) { created = false; return r; }
r = new Room(name); _rooms[name] = r; created = true; return r;
}
}
public static void Remove(string name) { lock (Sync) _rooms.Remove(name); }
public static List<Room> All() { lock (Sync) return _rooms.Values.ToList(); }
public static int Count { get { lock (Sync) return _rooms.Count; } }
}
@@ -0,0 +1,51 @@
using System.Collections;
namespace CampusNet.Services;
/// <summary>
/// Joins that hang on the loading screen (25%, "Waiting to receive buffered message"). A joiner
/// that loads streams its transform (event 28) and sends one request to the master client
/// (receiver group 2) within seconds; the stuck ones do neither (Rec Center 2026-09-26: every
/// join after a certain point hung while the players already inside were fine). After
/// AfterSeconds with neither, the Campus server is told (POST /photon/stuckload, localhost): it
/// stops matchmaking anyone into that instance and moves the player out of it, so they are not
/// left loading forever and their next join gets a working instance. The room's creator is
/// exempt (their load builds the room). CAMPUSNET_STUCK_LOAD_SECONDS=0 turns it off.
/// </summary>
public static class StuckLoad
{
public static readonly int AfterSeconds =
int.TryParse(Environment.GetEnvironmentVariable("CAMPUSNET_STUCK_LOAD_SECONDS"), out var s) ? (s <= 0 ? int.MaxValue : s) : 90;
private static readonly string Url = Environment.GetEnvironmentVariable("CAMPUSNET_STUCKLOAD_URL") ?? "http://127.0.0.1:8080/photon/stuckload";
private static readonly HttpClient Http = new() { Timeout = TimeSpan.FromSeconds(3) };
public static void Report(string userId, string room, int secs)
{
Log.Warn($"[stuck-load] u{userId} in '{room}' has not finished loading after {secs}s - reporting");
if (string.IsNullOrEmpty(userId) || !ulong.TryParse(userId, out _)) return;
_ = Task.Run(async () =>
{
try
{
using var body = new FormUrlEncodedContent(new[]
{
new KeyValuePair<string, string>("id", userId),
new KeyValuePair<string, string>("room", room),
new KeyValuePair<string, string>("seconds", secs.ToString()),
});
using var resp = await Http.PostAsync(Url, body);
}
catch (Exception ex) { Log.Warn($"[stuck-load] report for u{userId} failed: {ex.Message}"); }
});
}
/// <summary>Does any string inside the payload contain this text?</summary>
public static bool ContainsString(object? o, string needle, int depth = 0)
{
if (o == null || depth > 4) return false;
if (o is string str) return str.Contains(needle, StringComparison.Ordinal);
if (o is Hashtable ht) { foreach (DictionaryEntry e in ht) if (ContainsString(e.Value, needle, depth + 1)) return true; return false; }
if (o is IEnumerable en and not byte[]) { int n = 0; foreach (var x in en) { if (n++ > 64) break; if (ContainsString(x, needle, depth + 1)) return true; } }
return false;
}
}
@@ -0,0 +1,90 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace CampusNet.Services;
/// <summary>
/// Two tokens live here. (1) The Campus server's photonAuthToken - an HS256 JWT (sub = account id,
/// aud = photon room id) the client presents as custom-auth data on the name server; validated with the
/// shared key. (2) The "secret" this server mints for the master/game hops (Photon hands the client an
/// opaque secret after the first Authenticate and the client presents it on the next server).
/// </summary>
public static class Tokens
{
// Must be the SAME key the Campus server signs with. It reads CAMPUS_KEY or Data/jwt_key.txt,
// so this reads the env vars and then that same file (/opt/campus/Data/jwt_key.txt, override
// with CAMPUSNET_JWT_KEY_FILE): one file rotates both servers together.
public static string Key = Environment.GetEnvironmentVariable("CAMPUSNET_JWT_KEY")
?? Environment.GetEnvironmentVariable("CAMPUS_KEY")
?? KeyFromFile()
?? "REDACTED";
private static string? KeyFromFile()
{
try
{
var path = Environment.GetEnvironmentVariable("CAMPUSNET_JWT_KEY_FILE") ?? "/opt/campus/Data/jwt_key.txt";
if (!File.Exists(path)) return null;
var k = File.ReadAllText(path).Trim();
return k.Length >= 32 ? k : null;
}
catch { return null; }
}
private static byte[] Hmac(string data) => HMACSHA256.HashData(Encoding.UTF8.GetBytes(Key), Encoding.UTF8.GetBytes(data));
private static string B64Url(byte[] b) => Convert.ToBase64String(b).TrimEnd('=').Replace('+', '-').Replace('/', '_');
private static byte[] FromB64Url(string s)
{
s = s.Replace('-', '+').Replace('_', '/');
while (s.Length % 4 != 0) s += "=";
return Convert.FromBase64String(s);
}
/// <summary>Validate the Campus JWT; returns (accountId, audience/room) or null.</summary>
public static (string userId, string? aud)? ValidateCampusJwt(string? jwt)
{
if (string.IsNullOrEmpty(jwt)) return null;
var parts = jwt.Split('.');
if (parts.Length != 3) return null;
try
{
var expected = B64Url(Hmac(parts[0] + "." + parts[1]));
if (!CryptographicOperations.FixedTimeEquals(Encoding.ASCII.GetBytes(expected), Encoding.ASCII.GetBytes(parts[2]))) return null;
using var doc = JsonDocument.Parse(FromB64Url(parts[1]));
var root = doc.RootElement;
// A numeric exp that has passed is refused (older tokens carried exp as a string, or
// none, and are left to the signature check alone).
if (root.TryGetProperty("exp", out var e) && e.ValueKind == JsonValueKind.Number
&& e.TryGetInt64(out var exp) && exp < DateTimeOffset.UtcNow.ToUnixTimeSeconds()) return null;
string? sub = root.TryGetProperty("sub", out var s) ? s.ToString() : null;
string? aud = root.TryGetProperty("aud", out var a) ? a.ToString() : null;
return sub == null ? null : (sub, aud);
}
catch { return null; }
}
/// <summary>Mint the master/game "secret": userId|nick|expiry, HMAC-signed.</summary>
public static string MintSecret(string userId, string nick)
{
long exp = DateTimeOffset.UtcNow.AddHours(12).ToUnixTimeSeconds();
string body = $"{userId}|{nick}|{exp}";
return B64Url(Encoding.UTF8.GetBytes(body)) + "." + B64Url(Hmac(body));
}
public static (string userId, string nick)? ValidateSecret(string? secret)
{
if (string.IsNullOrEmpty(secret)) return null;
var i = secret.IndexOf('.');
if (i <= 0) return null;
try
{
string body = Encoding.UTF8.GetString(FromB64Url(secret[..i]));
if (B64Url(Hmac(body)) != secret[(i + 1)..]) return null;
var f = body.Split('|');
if (f.Length != 3 || !long.TryParse(f[2], out var exp) || exp < DateTimeOffset.UtcNow.ToUnixTimeSeconds()) return null;
return (f[0], f[1]);
}
catch { return null; }
}
}
@@ -0,0 +1,139 @@
# 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).
## Event cache and master client (2026-09-23)
Rec Room leans on Photon's room cache: most game state goes out as `AddToRoomCacheGlobal` (5)
and is later cleared with `RemoveFromRoomCache` (6) - ev 22/29/31/33/36 in the traces. Three
server-side mistakes made late joiners (Dodgeball mid-round) replay stale round/team/spawn state:
- A global event was stored with sender **0**. Photon keeps the real sender ("global" only means
it outlives them): it is replayed as coming from them, and a removal's actor filter matches it.
- `RemoveFromRoomCache` from a non-master could only remove its own events, so a clear run by
anyone but the master was silently ignored. Photon lets any actor remove matching events.
Event code 0 on a removal means "any code".
- A removal that carried a data filter was also **delivered** to the room as a normal event.
On a removal the data is only the filter; Photon never sends it. Rec Room's clears were being
re-run by everyone as new RPCs (thousands a day).
Master hand-off: PUN's `SetMasterClient` writes room property 248 with the current master as the
expected value. 248 is live (lowest active actor unless overridden), never stored, so the plain
CAS always refused it. The server now honours it (`Room.MasterOverride`) and broadcasts 248.
## CleanupCacheOnLeave defaults OFF (2026-09-26)
PUN only sends op param 241 / game prop 249 when RoomOptions.CleanupCacheOnLeave is TRUE, so a room created without them wants its cache kept. Rec Room creates rooms without them. CampusNet used to default to true, which deleted the room creator's cached events (the BufferedEventHelper RoomLoad state) when they left: the next joiner got 3 cached events instead of ~40 and sat on the loading screen at 25% forever ("Waiting to receive buffered message"). Default is now false; Rec Room clears departed players' events itself with RemoveFromRoomCacheForActorsLeft.
## RemoveFromRoomCacheForActorsLeft keeps global events (2026-09-27)
Global events keep their real sender (see above) so replays and RemoveFromRoomCache filters match them. That made
`RemoveFromRoomCacheForActorsLeft` (cache op 7) delete them too. Rec Room's new master sends it after a player
leaves, so when a room's creator left, the BufferedEventHelper RoomLoad slices were wiped. Those slices are all
global (cache=5).
The next joiner then replayed a handful of events instead of about 50 and hung at 25% ("Waiting to receive buffered
message"). Seen in every stuck-load case on 2026-09-26/27: 51→3, 56→4, 52→7, 51→22.
In Photon a global event belongs to the room (actor 0), so op 7 never touches it. CampusNet now removes only
non-global events of departed actors, and logs each cleanup ("cleanup for departed players: removed X of Y, kept Z
global").
## Departed senders' global events replay as actor 0 (2026-09-27)
Keeping global events after their sender left (the previous section) was not enough. They were replayed under the departed sender's actor number, and Rec Room's BufferedEventHelper only accepts buffered room-load messages from the current master or from actor 0.
Client check (RVA 0x8293b40): sender == 0 or sender == master, else "Received buffered message from non-master client [1] Current Master is [2]. Discarding...". So a joiner still hung at 25% even with all 53 cached events replayed (room-2-2-461102059, 11:11).
The join replay now sends a global event whose sender is no longer in the room as actor 0, the room, as Photon does. Events of present actors keep their real sender, so RemoveFromRoomCache sender filters still match.
## Every global event replays as actor 0 (2026-09-27, later)
Departed senders were not the only case. When a player drops, the master can hand master on to someone else while the original sender stays in the room (room-2-2-49039038: a1 -> a2). The joiner then got the room-load slices from a1, who was no longer master, and discarded all 54 ("Received buffered message from non-master client [1] Current Master is [2]"). Stuck at 25% again.
The replay now sends EVERY global (cache=5) event as actor 0, as Photon's AddToRoomCacheGlobal defines ("cached for actor 0"). The cache still stores the real sender, so RemoveFromRoomCache sender filters keep matching. Non-global (cache=4) events, such as instantiates, keep their real sender.
## A new session never inherits an old actor (2026-09-27)
Staff connections are kept through 10 min of silence (AfkExempt), so a hard-quit or restarted game leaves its old
connection in the room. The new game's normal join (JoinMode 0/1) used to take over that actor, cache and all,
so it was replayed its OWN earlier spawns: a second Maker Pen with the same view id ("NetworkView ID duplicate
found: 100029"), whose teardown threw in `MakerPen.ShowConfigFlow` on every leave. The client never finished
leaving the dorm ("Timed out waiting for stable state for action: Leave Room", FailedToSpawnPlayer 8712), so
every other room hung at "PREPARING 0%".
Only JoinMode 3 (RejoinOnly, PUN's ReconnectAndRejoin) resumes an actor now. Any other join from a user who
still has an actor removes that ghost as departed: its non-global cached events go, others get a Leave event,
and the newcomer is a new actor. If the ghost was alone, the room is reset as if it had closed.