From 1606b859666336cdf1c049b342da768841e97674 Mon Sep 17 00:00:00 2001
From: Rollocraft <70059106+Rollocraft@users.noreply.github.com>
Date: Sat, 29 Aug 2026 08:53:33 +0000
Subject: [PATCH 1/7] Harden the wire codec, the socket framing and two entity
scans
Five small fixes, adapted from t1garbiznisbrate-ship-it's work in #18.
The float codec allocated a four-byte array per value through
BitConverter.GetBytes and relied on the runtime's endianness matching the
manual little-endian integer writes beside it. Floats are the densest thing
on this wire - a terrain brush or a road curve is little else - so both the
allocation and the unstated assumption sat on the hottest path. Writer and
reader now reinterpret through an overlaid struct and lay the bytes out
explicitly. Output is identical on a little-endian host, so the wire format
does not move.
The send loop wrote the four-byte length prefix and the payload as two
separate calls. Over TLS each call is its own record, so describing four
bytes of length cost about twenty-nine bytes of record overhead, and with
NoDelay set it was also its own TCP segment - close to a fixed tax on every
command. Payloads up to 8 KiB are now copied in behind their prefix and
written once. Larger ones keep the two-call path, where the copy would cost
more than the write saves.
Sockets ask for 1 MiB kernel buffers. A world transfer is tens of megabytes
through this socket and the defaults are sized for request/response traffic.
Best-effort: a platform that refuses keeps its default.
The TLS handshake had a read deadline but no write deadline, so a peer that
stalled mid-write wedged the thread the read timeout existed to protect.
Both are now set for the handshake and cleared after it.
ReadExactly dereferenced _stream without checking it. Close() clears that
field from another thread, so the read loop could fault between the close and
its own cancellation check and end the connection with an unhandled
NullReferenceException instead of a reason.
BlobReassembler sizes its buffer to the announced total. A savegame arrives
in 256 KiB chunks, so growing from the default meant a doubling and a full
large-object-heap copy roughly per chunk. The announced total is already
rejected above the channel's registered ceiling before we get here, so the
allocation is bounded by that ceiling rather than by the sender's claim.
NetUpgradeSyncSystem and DisasterSyncSystem read components off entities
taken from a query snapshot. A delete realized between the snapshot and the
loop - or the simulation reaping a finished disaster on its own cadence -
leaves a handle that no longer resolves, and GetComponentData on it throws
out of the entire pass, taking every other live entry with it. Both scans now
check Exists and the components they are about to read, and skip the one
entry rather than losing the pass.
Co-Authored-By: t1garbiznisbrate-ship-it <257155523+t1garbiznisbrate-ship-it@users.noreply.github.com>
---
.../Core/Networking/Tcp/FramedConnection.cs | 66 +++++++++++++++++--
.../Core/Protocol/Wire/NetworkReader.cs | 21 +++++-
.../Core/Protocol/Wire/NetworkWriter.cs | 30 +++++++--
.../Core/Session/BlobReassembler.cs | 9 ++-
.../Nets/NetUpgradeSyncSystem/Apply.cs | 24 ++++++-
.../Systems/Simulation/DisasterSyncSystem.cs | 13 ++++
6 files changed, 149 insertions(+), 14 deletions(-)
diff --git a/CS2MultiplayerMod/Core/Networking/Tcp/FramedConnection.cs b/CS2MultiplayerMod/Core/Networking/Tcp/FramedConnection.cs
index 041dae3..a4eb58a 100644
--- a/CS2MultiplayerMod/Core/Networking/Tcp/FramedConnection.cs
+++ b/CS2MultiplayerMod/Core/Networking/Tcp/FramedConnection.cs
@@ -60,6 +60,18 @@ internal sealed class FramedConnection
private Thread _readThread;
private int _closed; // 0 = open, 1 = closed (Interlocked guarded)
+ /// Kernel send/receive buffer size requested per socket.
+ private const int SocketBufferBytes = 1024 * 1024;
+
+ /// How long a peer may take over the TLS handshake before it is dropped.
+ private const int HandshakeTimeoutMs = 15000;
+
+ ///
+ /// Payloads at or below this size are copied behind their length prefix and written in
+ /// one call. Above it the copy costs more than the extra write saves.
+ ///
+ private const int SingleWriteThreshold = 8 * 1024;
+
/// Raised on the read thread once the connection is usable (TLS done).
public Action OnReady;
@@ -83,6 +95,16 @@ public FramedConnection(ConnectionId id, TcpClient client,
_serverCertificate = serverCertificate;
_clientTls = clientTls;
_client.NoDelay = true; // low latency matters more than packing for a co-op session
+ // A world transfer is tens of megabytes through this socket. The default kernel
+ // buffers are sized for request/response traffic and make the sender stall on a
+ // full window far more often than the link requires. Best-effort: a platform that
+ // refuses the size keeps its default rather than failing the connection.
+ try
+ {
+ _client.ReceiveBufferSize = SocketBufferBytes;
+ _client.SendBufferSize = SocketBufferBytes;
+ }
+ catch { /* keep the platform default */ }
try
{
@@ -129,15 +151,38 @@ private void SendLoop()
{
try
{
+ // Scratch for the combined prefix+payload write. Only this thread touches it.
+ byte[] framed = new byte[SingleWriteThreshold + 4];
+
foreach (byte[] payload in _sendQueue.GetConsumingEnumerable())
{
try
{
Stream stream = _stream;
if (stream == null) continue; // closed before the stream was ready
- WriteLength(payload.Length);
- stream.Write(_sendPrefix, 0, 4);
- stream.Write(payload, 0, payload.Length);
+
+ int length = payload.Length;
+ if (length <= SingleWriteThreshold)
+ {
+ // Copy the prefix and the payload into one buffer and issue a single
+ // write. Over TLS each Write becomes its own record, so a 4-byte
+ // prefix written separately carried ~29 bytes of record overhead to
+ // describe 4 bytes of length - and with NoDelay set it was also its
+ // own TCP segment. Most traffic on this socket is small commands, so
+ // that was close to a fixed tax per message.
+ framed[0] = (byte)(length & 0xFF);
+ framed[1] = (byte)((length >> 8) & 0xFF);
+ framed[2] = (byte)((length >> 16) & 0xFF);
+ framed[3] = (byte)((length >> 24) & 0xFF);
+ Buffer.BlockCopy(payload, 0, framed, 4, length);
+ stream.Write(framed, 0, length + 4);
+ }
+ else
+ {
+ WriteLength(length);
+ stream.Write(_sendPrefix, 0, 4);
+ stream.Write(payload, 0, length);
+ }
stream.Flush();
}
finally
@@ -251,10 +296,15 @@ private bool Upgrade()
if (_serverCertificate != null)
{
- raw.ReadTimeout = 15000; // a peer that stalls the TLS handshake gets dropped
+ // Both directions get a deadline: a peer that stalls the handshake mid-write
+ // wedges this thread exactly as one that stalls mid-read does, and only the
+ // read side was guarded.
+ raw.ReadTimeout = HandshakeTimeoutMs;
+ raw.WriteTimeout = HandshakeTimeoutMs;
var ssl = new SslStream(raw, false);
ssl.AuthenticateAsServer(_serverCertificate, false, SslProtocols.Tls12, false);
raw.ReadTimeout = Timeout.Infinite;
+ raw.WriteTimeout = Timeout.Infinite;
_channelBinding = TlsCertificate.HashOf(_serverCertificate);
_stream = ssl;
return true;
@@ -262,12 +312,16 @@ private bool Upgrade()
if (_clientTls)
{
+ raw.ReadTimeout = HandshakeTimeoutMs;
+ raw.WriteTimeout = HandshakeTimeoutMs;
var ssl = new SslStream(raw, false, (sender, cert, chain, errors) =>
{
_channelBinding = TlsCertificate.HashOf(cert);
return true; // trust is established by the password proof over this hash
});
ssl.AuthenticateAsClient("CS2MultiplayerMod", null, SslProtocols.Tls12, false);
+ raw.ReadTimeout = Timeout.Infinite;
+ raw.WriteTimeout = Timeout.Infinite;
_stream = ssl;
return true;
}
@@ -279,7 +333,11 @@ private bool Upgrade()
/// Read exactly bytes; false on clean EOF.
private bool ReadExactly(byte[] buffer, int count)
{
+ // Close() clears _stream from another thread; without this the read loop can
+ // dereference null between the close and its own cancellation check, and the
+ // connection ends in an unhandled NullReferenceException instead of a reason.
Stream stream = _stream;
+ if (stream == null) return false;
int read = 0;
while (read < count)
{
diff --git a/CS2MultiplayerMod/Core/Protocol/Wire/NetworkReader.cs b/CS2MultiplayerMod/Core/Protocol/Wire/NetworkReader.cs
index d02a2cc..348e77b 100644
--- a/CS2MultiplayerMod/Core/Protocol/Wire/NetworkReader.cs
+++ b/CS2MultiplayerMod/Core/Protocol/Wire/NetworkReader.cs
@@ -1,4 +1,5 @@
using System;
+using System.Runtime.InteropServices;
using System.Text;
namespace CS2MultiplayerMod.Core.Protocol
@@ -62,12 +63,28 @@ public long ReadLong()
return value;
}
+ ///
+ /// Mirror of : assemble the little-endian bits by
+ /// hand, then reinterpret. Replaces BitConverter.ToSingle, which read whatever
+ /// order the runtime happened to use rather than the order the writer states.
+ ///
public float ReadFloat()
{
Require(4);
- float value = BitConverter.ToSingle(_buffer, _position);
+ int bits = _buffer[_position]
+ | (_buffer[_position + 1] << 8)
+ | (_buffer[_position + 2] << 16)
+ | (_buffer[_position + 3] << 24);
_position += 4;
- return value;
+ return new FloatBits { Int = bits }.Float;
+ }
+
+ /// IEEE-754 reinterpretation without allocating. Shared shape with the writer.
+ [StructLayout(LayoutKind.Explicit)]
+ private struct FloatBits
+ {
+ [FieldOffset(0)] public float Float;
+ [FieldOffset(0)] public int Int;
}
public string ReadString()
diff --git a/CS2MultiplayerMod/Core/Protocol/Wire/NetworkWriter.cs b/CS2MultiplayerMod/Core/Protocol/Wire/NetworkWriter.cs
index fca3b12..1ca3ae8 100644
--- a/CS2MultiplayerMod/Core/Protocol/Wire/NetworkWriter.cs
+++ b/CS2MultiplayerMod/Core/Protocol/Wire/NetworkWriter.cs
@@ -1,4 +1,5 @@
using System;
+using System.Runtime.InteropServices;
using System.Text;
namespace CS2MultiplayerMod.Core.Protocol
@@ -59,12 +60,33 @@ public void WriteLong(long value)
}
}
+ ///
+ /// Reinterprets the float's bits through an overlaid int and writes them in the same
+ /// explicit little-endian order as .
+ ///
+ /// The previous BitConverter.GetBytes allocated a four-byte array per float and
+ /// relied on the runtime's own endianness matching the manual writes above. Floats are the
+ /// densest thing on this wire - a terrain brush or a road curve is little else - so that
+ /// allocation was the protocol's hottest, and the assumption was the one thing here not
+ /// written out. Bit-for-bit identical output on a little-endian host, so the wire format
+ /// is unchanged.
+ ///
public void WriteFloat(float value)
{
- // BitConverter is little-endian on every supported (x86/ARM) target, matching
- // the manual little-endian integer writes above.
- byte[] bytes = BitConverter.GetBytes(value);
- WriteBytes(bytes, 0, 4);
+ EnsureCapacity(4);
+ int bits = new FloatBits { Float = value }.Int;
+ _buffer[_length++] = (byte)(bits & 0xFF);
+ _buffer[_length++] = (byte)((bits >> 8) & 0xFF);
+ _buffer[_length++] = (byte)((bits >> 16) & 0xFF);
+ _buffer[_length++] = (byte)((bits >> 24) & 0xFF);
+ }
+
+ /// IEEE-754 reinterpretation without allocating. Shared shape with the reader.
+ [StructLayout(LayoutKind.Explicit)]
+ private struct FloatBits
+ {
+ [FieldOffset(0)] public float Float;
+ [FieldOffset(0)] public int Int;
}
public void WriteString(string value)
diff --git a/CS2MultiplayerMod/Core/Session/BlobReassembler.cs b/CS2MultiplayerMod/Core/Session/BlobReassembler.cs
index ec7f4c1..60781b6 100644
--- a/CS2MultiplayerMod/Core/Session/BlobReassembler.cs
+++ b/CS2MultiplayerMod/Core/Session/BlobReassembler.cs
@@ -13,12 +13,19 @@ namespace CS2MultiplayerMod.Core.Session
///
internal sealed class BlobReassembler
{
- private readonly MemoryStream _buffer = new MemoryStream();
+ private readonly MemoryStream _buffer;
public BlobReassembler(int expectedBytes, long nowMs)
{
ExpectedBytes = expectedBytes;
LastChunkAtMs = nowMs;
+ // Size the buffer to the announced total up front. A savegame arrives in 256 KiB
+ // chunks, so growing from the default capacity means a doubling and a full copy
+ // roughly every chunk - on the large object heap, for a payload measured in tens of
+ // megabytes. The caller has already rejected any total outside the channel's
+ // registered ceiling, so this allocation is bounded by that ceiling and not by
+ // whatever the sender claimed.
+ _buffer = expectedBytes > 0 ? new MemoryStream(expectedBytes) : new MemoryStream();
}
public int ExpectedBytes { get; }
diff --git a/CS2MultiplayerMod/Game/Sync/Systems/Nets/NetUpgradeSyncSystem/Apply.cs b/CS2MultiplayerMod/Game/Sync/Systems/Nets/NetUpgradeSyncSystem/Apply.cs
index b2885e9..042fb67 100644
--- a/CS2MultiplayerMod/Game/Sync/Systems/Nets/NetUpgradeSyncSystem/Apply.cs
+++ b/CS2MultiplayerMod/Game/Sync/Systems/Nets/NetUpgradeSyncSystem/Apply.cs
@@ -75,6 +75,14 @@ private int ApplyEdges(List<(Entity prefab, float3 a, float3 d, NetUpgradeComman
for (int i = 0; i < entities.Length && targets.Count > 0; i++)
{
Entity entity = entities[i];
+ // The array was materialized from a query; a delete realized between that
+ // snapshot and this loop leaves a handle here that no longer resolves, and
+ // GetComponentData on it throws out of the whole apply pass. Skipping the
+ // one edge costs nothing - the upgrade stays in targets and retries.
+ if (!EntityManager.Exists(entity) ||
+ !EntityManager.HasComponent(entity) ||
+ !EntityManager.HasComponent(entity)) continue;
+
Entity candidatePrefab = EntityManager.GetComponentData(entity).m_Prefab;
Bezier4x3 b = EntityManager.GetComponentData(entity).m_Bezier;
@@ -150,9 +158,14 @@ private int ApplyEdges(List<(Entity prefab, float3 a, float3 d, NetUpgradeComman
EntityManager.AddComponent(entity);
// The composition at each end (crosswalks, transitions) is selected
// per node - re-update them like the game's own commit does.
- Edge ends = EntityManager.GetComponentData(entity);
- TagUpdated(ends.m_Start);
- TagUpdated(ends.m_End);
+ // An edge normally has Edge, but the query admits anything with a Curve;
+ // a pending replacement can leave one without ends for a frame.
+ if (EntityManager.HasComponent(entity))
+ {
+ Edge ends = EntityManager.GetComponentData(entity);
+ TagUpdated(ends.m_Start);
+ TagUpdated(ends.m_End);
+ }
targets.RemoveAt(t);
applied++;
@@ -184,6 +197,11 @@ private int ApplyNodes(List<(Entity prefab, float3 pos, NetUpgradeCommand cmd)>
for (int i = 0; i < entities.Length; i++)
{
+ // Same staleness window as the edge scan above.
+ if (!EntityManager.Exists(entities[i]) ||
+ !EntityManager.HasComponent(entities[i]) ||
+ !EntityManager.HasComponent(entities[i])) continue;
+
float3 pos = EntityManager.GetComponentData(entities[i]).m_Position;
if (math.abs(pos.y - wanted.y) > NodeMatchMaxDy) continue;
float distSq = math.distancesq(pos.xz, wanted.xz);
diff --git a/CS2MultiplayerMod/Game/Sync/Systems/Simulation/DisasterSyncSystem.cs b/CS2MultiplayerMod/Game/Sync/Systems/Simulation/DisasterSyncSystem.cs
index 9cf2d79..f1e288d 100644
--- a/CS2MultiplayerMod/Game/Sync/Systems/Simulation/DisasterSyncSystem.cs
+++ b/CS2MultiplayerMod/Game/Sync/Systems/Simulation/DisasterSyncSystem.cs
@@ -174,6 +174,14 @@ private void CapturePhenomena(MultiplayerSession session)
Entity entity = events[i];
if (WasRealizedThisFrame(entity)) continue;
+ // A disaster event is short-lived by nature: the simulation ends and reaps
+ // one at its own cadence, which can fall between this query snapshot and the
+ // read below. Reading a component off the reaped handle throws out of the
+ // whole capture pass, taking every other live event with it.
+ if (!EntityManager.Exists(entity) ||
+ !EntityManager.HasComponent(entity) ||
+ !EntityManager.HasComponent(entity)) continue;
+
Entity prefab;
string prefabName;
if (!TryNamePrefab(entity, out prefab, out prefabName)) continue;
@@ -233,6 +241,11 @@ private void CaptureSurges(MultiplayerSession session)
Entity entity = events[i];
if (WasRealizedThisFrame(entity)) continue;
+ // Same reap window as the phenomenon scan above.
+ if (!EntityManager.Exists(entity) ||
+ !EntityManager.HasComponent(entity) ||
+ !EntityManager.HasComponent(entity)) continue;
+
Entity prefab;
string prefabName;
if (!TryNamePrefab(entity, out prefab, out prefabName)) continue;
From 9702a40946acdd4b349b8d6be6585b592ae91d2c Mon Sep 17 00:00:00 2001
From: Rollocraft <70059106+Rollocraft@users.noreply.github.com>
Date: Sat, 29 Aug 2026 08:57:25 +0000
Subject: [PATCH 2/7] Stop sending a cursor that is not moving, and give
clients a player list
Adapted from t1garbiznisbrate-ship-it's work in #18.
PlayerCursorSyncSystem transmitted at 10 Hz for the whole session regardless
of whether the camera had moved, so a player reading a panel or sitting in a
menu - the common case in a co-op session - sent ten packets a second to say
nothing had changed. It now sends at that cadence only while the focus, eye
or yaw actually move, and drops to a 1 Hz keepalive otherwise, which is well
inside the five seconds after which a peer stops drawing the marker.
The same system now owns every camera move the mod makes, because it holds
the only CameraUpdateSystem reference. Chat commands record an intent on the
service - a one-shot jump target, or a player to follow - and this system
carries it out on its next update. Follow mode ends when the player takes
their own camera back, detected by the pivot drifting from where the follow
lerp last put it rather than by polling keys: nothing to maintain as
keybindings change, and it cannot fire while someone is typing.
The tree channel's periodic sweep copied every tree in the city into a
NativeArray to pick at most MaxRecords of them - a six-figure allocation per
sweep on a forested map, and the sweep, not the send, is what this channel
costs the host. It now walks the archetype chunks the ECS already holds. The
round-robin cursor is chunk-granular, so every tree is still eventually
visited.
Peer latency was the raw last sample, which swings with whatever the OS was
doing when the echo landed. It is now the Jacobson/Karels smoothed estimate
with the standard 1/8 and 1/4 gains, and the mean deviation it produces is
kept as jitter - the "is it steady" half of the story that a single number
cannot tell.
A client was handed an empty player list and rendered nothing, so a
two-player session looked like a single-player one from one side of it.
Clients now build a roster from the host peer plus the players they are
tracking positions for. Another client's name genuinely is not known there -
no roster travels to clients - so it reads "Player 3" rather than inventing
one, and an unmeasured latency is -1 so the panel can show nothing instead of
a misleading zero. MultiplayerSession.HostPlayerId is public for the same
reason: the game layer has to tell which entry is the host, and guessing 0
mislabels everyone.
Co-Authored-By: t1garbiznisbrate-ship-it <257155523+t1garbiznisbrate-ship-it@users.noreply.github.com>
---
.../Session/MultiplayerSession/Messaging.cs | 2 +-
.../MultiplayerSession/MultiplayerSession.cs | 7 +-
CS2MultiplayerMod/Core/Session/Peers/Peer.cs | 39 +++-
.../MultiplayerService/MultiplayerService.cs | 213 +++++++++++++++---
.../WorldTransfer/WorldTransfer.cs | 4 +
.../Sync/Channels/World/TreeStateChannel.cs | 35 ++-
.../Sync/Players/PlayerCursorSyncSystem.cs | 143 +++++++++++-
7 files changed, 391 insertions(+), 52 deletions(-)
diff --git a/CS2MultiplayerMod/Core/Session/MultiplayerSession/Messaging.cs b/CS2MultiplayerMod/Core/Session/MultiplayerSession/Messaging.cs
index 5b100e7..5f3a502 100644
--- a/CS2MultiplayerMod/Core/Session/MultiplayerSession/Messaging.cs
+++ b/CS2MultiplayerMod/Core/Session/MultiplayerSession/Messaging.cs
@@ -17,7 +17,7 @@ private void HandleHeartbeat(ConnectionId connection, Peer peer, Heartbeat heart
if (heartbeat.EchoOfMs > 0)
{
long rtt = nowUnixMs - heartbeat.EchoOfMs;
- if (peer != null && rtt >= 0 && rtt < 60000) peer.LatencyMs = (int)rtt;
+ if (peer != null && rtt >= 0 && rtt < 60000) peer.RecordRttSample(rtt);
return;
}
diff --git a/CS2MultiplayerMod/Core/Session/MultiplayerSession/MultiplayerSession.cs b/CS2MultiplayerMod/Core/Session/MultiplayerSession/MultiplayerSession.cs
index 658f6c1..2aa96d8 100644
--- a/CS2MultiplayerMod/Core/Session/MultiplayerSession/MultiplayerSession.cs
+++ b/CS2MultiplayerMod/Core/Session/MultiplayerSession/MultiplayerSession.cs
@@ -24,7 +24,12 @@ public sealed partial class MultiplayerSession
/// pre-handshake socket is never held open indefinitely.
private const int JoinApprovalTimeoutMs = 120000;
- private const int HostPlayerId = 1;
+ ///
+ /// The host always holds player id 1; assigned client ids start above it. Public
+ /// because the game layer has to be able to tell which entry in a roster is the host,
+ /// and guessing 0 - the default of an unset int - silently mislabels everyone.
+ ///
+ public const int HostPlayerId = 1;
/// Reassembling blobs allowed at once on a client.
private const int MaxActiveBlobs = 4;
diff --git a/CS2MultiplayerMod/Core/Session/Peers/Peer.cs b/CS2MultiplayerMod/Core/Session/Peers/Peer.cs
index f564234..70ea3d5 100644
--- a/CS2MultiplayerMod/Core/Session/Peers/Peer.cs
+++ b/CS2MultiplayerMod/Core/Session/Peers/Peer.cs
@@ -28,9 +28,46 @@ public sealed class Peer
/// When the underlying connection appeared - pending peers expire on this.
public long ConnectedAtUnixMs;
- /// Most recent round-trip estimate in milliseconds, or -1 if unknown.
+ ///
+ /// Smoothed round-trip estimate in milliseconds, or -1 before the first sample.
+ /// This is the number to show a player: a single sample swings with whatever the OS
+ /// was doing when the echo landed, and a readout that flickers between 20 and 90 tells
+ /// nobody anything.
+ ///
public int LatencyMs = -1;
+ /// Round-trip variation in milliseconds - the "is it steady" half of the story.
+ public int JitterMs;
+
+ // Jacobson/Karels, the same estimator TCP uses for its retransmit timer: a smoothed
+ // round-trip time and a smoothed mean deviation, each pulled a fixed fraction of the
+ // way towards the newest sample. The 1/8 and 1/4 gains are the standard ones.
+ private const double SrttGain = 0.125;
+ private const double RttVarGain = 0.25;
+
+ private double _srttMs = -1.0;
+ private double _rttVarMs;
+
+ /// Fold one measured round-trip into the estimate.
+ public void RecordRttSample(long rttMs)
+ {
+ if (_srttMs < 0)
+ {
+ // First sample: seed the estimator with it, and half of it as the deviation.
+ _srttMs = rttMs;
+ _rttVarMs = rttMs / 2.0;
+ }
+ else
+ {
+ double delta = rttMs - _srttMs;
+ _srttMs += SrttGain * delta;
+ _rttVarMs += RttVarGain * (System.Math.Abs(delta) - _rttVarMs);
+ }
+
+ LatencyMs = (int)System.Math.Round(_srttMs);
+ JitterMs = (int)System.Math.Round(_rttVarMs);
+ }
+
/// Remote IP for logging/ban bookkeeping. May be null.
public string RemoteAddress;
diff --git a/CS2MultiplayerMod/Game/MultiplayerService/MultiplayerService.cs b/CS2MultiplayerMod/Game/MultiplayerService/MultiplayerService.cs
index 5c4fb6e..3fe6f30 100644
--- a/CS2MultiplayerMod/Game/MultiplayerService/MultiplayerService.cs
+++ b/CS2MultiplayerMod/Game/MultiplayerService/MultiplayerService.cs
@@ -1,6 +1,10 @@
+using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
+using System.Globalization;
+using System.Threading;
+using Unity.Mathematics;
using CS2MultiplayerMod.Core.Diagnostics;
using CS2MultiplayerMod.Core.Protocol.Messages;
using CS2MultiplayerMod.Core.Session;
@@ -88,6 +92,105 @@ public MultiplayerService(IModLogger log)
/// Latest known positions of the other players, for rendering their cursors.
public IEnumerable RemotePlayers => _remotePlayers.Values;
+ /// How many remote players are tracked, without walking the enumerator.
+ public int RemotePlayerCount => _remotePlayers.Count;
+
+ /// The tracked position of one player, or null when that id is unknown.
+ public RemotePlayer FindRemotePlayer(int playerId)
+ {
+ RemotePlayer player;
+ return _remotePlayers.TryGetValue(playerId, out player) ? player : null;
+ }
+
+ ///
+ /// Resolve a player from what someone typed: an exact id, then an exact name, then a
+ /// unique prefix. A prefix that matches more than one player resolves to nothing rather
+ /// than to an arbitrary one - following the wrong partner is worse than being told to be
+ /// more specific.
+ ///
+ public RemotePlayer FindRemotePlayerByName(string query)
+ {
+ if (string.IsNullOrEmpty(query)) return null;
+ query = query.Trim();
+ if (query.Length == 0) return null;
+
+ int id;
+ if (int.TryParse(query, NumberStyles.Integer, CultureInfo.InvariantCulture, out id))
+ {
+ RemotePlayer byId = FindRemotePlayer(id);
+ if (byId != null) return byId;
+ }
+
+ RemotePlayer exact = null;
+ RemotePlayer prefix = null;
+ bool prefixAmbiguous = false;
+ foreach (RemotePlayer player in _remotePlayers.Values)
+ {
+ string name = PlayerDisplayName(player.PlayerId);
+ if (string.Equals(name, query, StringComparison.OrdinalIgnoreCase)) exact = player;
+ else if (name.StartsWith(query, StringComparison.OrdinalIgnoreCase))
+ {
+ if (prefix != null) prefixAmbiguous = true;
+ prefix = player;
+ }
+ }
+ if (exact != null) return exact;
+ return prefixAmbiguous ? null : prefix;
+ }
+
+ ///
+ /// The best name we can put on a player id. A client is never sent a roster, so for
+ /// anyone but the host it can only offer the id - saying so plainly beats inventing a
+ /// name that will not match what that player calls themselves.
+ ///
+ public string PlayerDisplayName(int playerId)
+ {
+ foreach (Peer peer in _session.Peers)
+ if (peer.PlayerId == playerId && !string.IsNullOrEmpty(peer.Name)) return peer.Name;
+ if (playerId == _session.LocalPlayerId) return _session.LocalPlayerName;
+ if (playerId == MultiplayerSession.HostPlayerId) return "Host";
+ return "Player " + playerId;
+ }
+
+ // ---- Camera intent -----------------------------------------------------------
+ // Chat commands run on the UI thread and have no business touching the game camera.
+ // They record what they want here; PlayerCursorSyncSystem, which owns the camera
+ // reference, carries it out on its next update.
+
+ private int _followPlayerId = -1;
+ private float3 _cameraJump;
+ private int _cameraJumpPending; // 0/1, Interlocked so the consumer takes it exactly once
+
+ /// The player the camera is following, or -1.
+ public int FollowPlayerId => Volatile.Read(ref _followPlayerId);
+
+ /// Ask the camera to jump to a point on its next frame.
+ public void RequestCameraJump(float3 target)
+ {
+ _cameraJump = target;
+ Interlocked.Exchange(ref _cameraJumpPending, 1);
+ }
+
+ /// Consume a pending jump. True exactly once per request.
+ public bool TakeCameraJump(out float3 target)
+ {
+ if (Interlocked.Exchange(ref _cameraJumpPending, 0) == 0)
+ {
+ target = default(float3);
+ return false;
+ }
+ target = _cameraJump;
+ return true;
+ }
+
+ /// Follow a player's camera focus until they stop reporting or ours moves.
+ public void StartFollowing(int playerId) => Volatile.Write(ref _followPlayerId, playerId);
+
+ public void StopFollowing() => Volatile.Write(ref _followPlayerId, -1);
+
+ /// Post a line into the local chat feed without sending it to anyone.
+ public void AppendSystemChat(string text) => AppendChatEntry(null, text);
+
/// The joining client's place in the world-handover flow.
public ClientWorldPhase WorldPhase => _phase;
@@ -369,42 +472,98 @@ public void BanPlayerFromUi(int playerId)
_log.Warn("[MP] Ignored ban request for unavailable player #" + playerId + ".");
}
+ ///
+ /// The participant list the in-game panel renders. The host builds it from its peer
+ /// table; a client has no peer table beyond the host connection, so it builds one from
+ /// the players it is tracking positions for. Either way the local player is included and
+ /// is never kickable.
+ ///
+ /// A client used to be handed an empty array and rendered nothing at all, which made a
+ /// two-player session look like a single-player one from one side of it.
+ ///
private void RefreshPlayerListJson()
{
lock (_chatLock)
{
- if (_session.Role != SessionRole.Host)
+ switch (_session.Role)
{
- _playerListJson = "[]";
- return;
+ case SessionRole.Host: _playerListJson = BuildHostPlayerList(); break;
+ case SessionRole.Client: _playerListJson = BuildClientPlayerList(); break;
+ default: _playerListJson = "[]"; break;
}
+ }
+ }
- var peers = new List();
- foreach (Peer peer in _session.Peers)
- if (peer.Handshaked) peers.Add(peer);
- peers.Sort((a, b) => a.PlayerId.CompareTo(b.PlayerId));
-
- var sb = new System.Text.StringBuilder((peers.Count + 1) * 56 + 2);
- sb.Append("[{\"id\":").Append(_session.LocalPlayerId).Append(",\"name\":");
- AppendJsonString(sb, _session.LocalPlayerName);
- sb.Append(",\"isHost\":true}]");
+ private string BuildHostPlayerList()
+ {
+ var peers = new List();
+ foreach (Peer peer in _session.Peers)
+ if (peer.Handshaked) peers.Add(peer);
+ peers.Sort((a, b) => a.PlayerId.CompareTo(b.PlayerId));
+
+ var sb = new System.Text.StringBuilder((peers.Count + 1) * 72 + 2);
+ sb.Append('[');
+ AppendPlayerEntry(sb, _session.LocalPlayerId, _session.LocalPlayerName,
+ isHost: true, isYou: true, latencyMs: 0);
+ for (int i = 0; i < peers.Count; i++)
+ {
+ sb.Append(',');
+ AppendPlayerEntry(sb, peers[i].PlayerId, peers[i].Name,
+ isHost: false, isYou: false, latencyMs: peers[i].LatencyMs);
+ }
+ sb.Append(']');
+ return sb.ToString();
+ }
- if (peers.Count > 0)
- {
- // Replace the closing bracket while appending keeps this a single,
- // small allocation and reuses the chat JSON escaping rules.
- sb.Length--;
- for (int i = 0; i < peers.Count; i++)
- {
- Peer peer = peers[i];
- sb.Append(",{\"id\":").Append(peer.PlayerId).Append(",\"name\":");
- AppendJsonString(sb, peer.Name);
- sb.Append(",\"isHost\":false}");
- }
- sb.Append(']');
- }
- _playerListJson = sb.ToString();
+ private string BuildClientPlayerList()
+ {
+ // The host is the one peer a client holds, and it carries the measured latency.
+ Peer hostPeer = null;
+ foreach (Peer peer in _session.Peers)
+ if (peer.Handshaked || peer.PlayerId == MultiplayerSession.HostPlayerId) { hostPeer = peer; break; }
+
+ var others = new List();
+ foreach (RemotePlayer player in _remotePlayers.Values)
+ if (player.PlayerId != _session.LocalPlayerId &&
+ player.PlayerId != MultiplayerSession.HostPlayerId) others.Add(player.PlayerId);
+ others.Sort();
+
+ var sb = new System.Text.StringBuilder((others.Count + 2) * 72 + 2);
+ sb.Append('[');
+ AppendPlayerEntry(sb, MultiplayerSession.HostPlayerId,
+ hostPeer != null && !string.IsNullOrEmpty(hostPeer.Name) ? hostPeer.Name : "Host",
+ isHost: true, isYou: _session.LocalPlayerId == MultiplayerSession.HostPlayerId,
+ latencyMs: hostPeer != null ? hostPeer.LatencyMs : -1);
+
+ sb.Append(',');
+ AppendPlayerEntry(sb, _session.LocalPlayerId, _session.LocalPlayerName,
+ isHost: false, isYou: true, latencyMs: 0);
+
+ for (int i = 0; i < others.Count; i++)
+ {
+ sb.Append(',');
+ // No roster travels to clients, so another client's name is not known here.
+ // PlayerDisplayName says "Player 3" rather than inventing one.
+ AppendPlayerEntry(sb, others[i], PlayerDisplayName(others[i]),
+ isHost: false, isYou: false, latencyMs: -1);
}
+ sb.Append(']');
+ return sb.ToString();
+ }
+
+ ///
+ /// One roster entry. A latency of -1 means "not measured from here" and the panel
+ /// shows nothing rather than a misleading zero.
+ ///
+ private static void AppendPlayerEntry(System.Text.StringBuilder sb, int id, string name,
+ bool isHost, bool isYou, int latencyMs)
+ {
+ sb.Append("{\"id\":").Append(id).Append(",\"name\":");
+ AppendJsonString(sb, name);
+ sb.Append(",\"isHost\":").Append(isHost ? "true" : "false");
+ sb.Append(",\"isYou\":").Append(isYou ? "true" : "false");
+ sb.Append(",\"latency\":").Append(latencyMs);
+ sb.Append('}');
}
diff --git a/CS2MultiplayerMod/Game/MultiplayerService/WorldTransfer/WorldTransfer.cs b/CS2MultiplayerMod/Game/MultiplayerService/WorldTransfer/WorldTransfer.cs
index 92ea668..8b58088 100644
--- a/CS2MultiplayerMod/Game/MultiplayerService/WorldTransfer/WorldTransfer.cs
+++ b/CS2MultiplayerMod/Game/MultiplayerService/WorldTransfer/WorldTransfer.cs
@@ -234,7 +234,11 @@ private void RecordRemotePlayer(PlayerStateMessage state)
// Ignore our own echo; we already know where we are.
if (state.PlayerId == _session.LocalPlayerId) return;
+ int before = _remotePlayers.Count;
var player = _remotePlayers.GetOrAdd(state.PlayerId, id => new RemotePlayer { PlayerId = id });
+ // A client's roster is built from exactly this dictionary - it is never sent a peer
+ // list - so a player first appearing here is a membership change for it.
+ if (_remotePlayers.Count != before) RefreshPlayerListJson();
player.X = state.PosX;
player.Y = state.PosY;
player.Z = state.PosZ;
diff --git a/CS2MultiplayerMod/Game/Sync/Channels/World/TreeStateChannel.cs b/CS2MultiplayerMod/Game/Sync/Channels/World/TreeStateChannel.cs
index a8382a7..eae3d66 100644
--- a/CS2MultiplayerMod/Game/Sync/Channels/World/TreeStateChannel.cs
+++ b/CS2MultiplayerMod/Game/Sync/Channels/World/TreeStateChannel.cs
@@ -95,23 +95,34 @@ public bool Capture(EntityManager em, NetworkWriter writer)
if (included.Add(entity)) TryCapture(em, entity, records);
}
- // ToEntityArray copies every tree in the city, so the sweep - not the send - is what
- // this channel costs the host. Prioritized trees still go out on every snapshot.
+ // The sweep - not the send - is what this channel costs the host: it only ever ships
+ // MaxRecords, but it used to copy every tree in the city into a NativeArray to pick
+ // them, which on a forested map is a six-figure allocation per sweep. Walking the
+ // archetype chunks instead reads the same entities out of the chunks the ECS already
+ // holds. The round-robin cursor is now chunk-granular: one chunk is drained before
+ // the cursor moves on, which keeps the same guarantee that every tree is eventually
+ // visited. Prioritized trees still go out on every snapshot.
if (_captureTick++ % SnapshotsPerSweep == 0)
{
- NativeArray trees = _trees.ToEntityArray(Allocator.Temp);
+ NativeArray chunks = _trees.ToArchetypeChunkArray(Allocator.Temp);
try
{
- if (trees.Length > 0)
+ if (chunks.Length > 0)
{
- if (_cursor >= trees.Length) _cursor = 0;
- int scanned = 0;
- while (scanned < trees.Length && records.Count < TreeStateBatch.MaxRecords)
+ EntityTypeHandle entityType = em.GetEntityTypeHandle();
+ if (_cursor >= chunks.Length) _cursor = 0;
+ int scannedChunks = 0;
+ while (scannedChunks < chunks.Length && records.Count < TreeStateBatch.MaxRecords)
{
- Entity entity = trees[_cursor];
- _cursor = (_cursor + 1) % trees.Length;
- scanned++;
- if (included.Add(entity)) TryCapture(em, entity, records);
+ NativeArray chunkEntities = chunks[_cursor].GetNativeArray(entityType);
+ for (int i = 0; i < chunkEntities.Length &&
+ records.Count < TreeStateBatch.MaxRecords; i++)
+ {
+ Entity entity = chunkEntities[i];
+ if (included.Add(entity)) TryCapture(em, entity, records);
+ }
+ _cursor = (_cursor + 1) % chunks.Length;
+ scannedChunks++;
}
}
else
@@ -121,7 +132,7 @@ public bool Capture(EntityManager em, NetworkWriter writer)
}
finally
{
- trees.Dispose();
+ chunks.Dispose();
}
}
diff --git a/CS2MultiplayerMod/Game/Sync/Players/PlayerCursorSyncSystem.cs b/CS2MultiplayerMod/Game/Sync/Players/PlayerCursorSyncSystem.cs
index e1254c3..4e274f5 100644
--- a/CS2MultiplayerMod/Game/Sync/Players/PlayerCursorSyncSystem.cs
+++ b/CS2MultiplayerMod/Game/Sync/Players/PlayerCursorSyncSystem.cs
@@ -12,10 +12,47 @@ namespace CS2MultiplayerMod.Game.Sync.Players
/// collect the other players' positions for drawing their cursors. Unlike the
/// city-state channels this is per-player and lossy: only the newest position
/// matters. Rendering the remote cursors is handled separately.
+ ///
+ /// This system also owns every camera move the mod makes, because it holds the only
+ /// reference to . The chat commands do not touch the
+ /// camera themselves; they record an intent on
+ /// ( and
+ /// ) and this system consumes it on the
+ /// next frame, on the thread that may safely do so.
///
public partial class PlayerCursorSyncSystem : GameSystemBase
{
- private const long SendIntervalMs = 100; // ~10 Hz
+ /// Cadence while the camera is being moved.
+ private const long MovingIntervalMs = 100; // ~10 Hz
+
+ ///
+ /// Cadence while it is not. A parked camera still has to report in - a peer that
+ /// hears nothing for StaleAfterMs stops drawing the marker - but ten times a
+ /// second to say "unchanged" is most of what this system used to send. A player
+ /// reading a panel or sitting in a menu is the common case in a co-op session.
+ ///
+ private const long IdleIntervalMs = 1000;
+
+ ///
+ /// Movement below this is not worth a packet. Squared metres for the positions;
+ /// the yaw threshold is radians and is roughly two degrees.
+ ///
+ private const float MovedDistanceSq = 0.1f;
+ private const float MovedYaw = 0.03f;
+
+ ///
+ /// How far the pivot may drift from where follow mode last put it before we take
+ /// that as the player moving their own camera and stop following. Testing the
+ /// camera rather than polling keys keeps this out of the game's input handling -
+ /// so it cannot fire while someone is typing, and needs no key list to maintain.
+ ///
+ private const float FollowBreakDistanceSq = 400f; // 20 m
+
+ /// Fraction of the gap to the followed player closed per frame, time-scaled.
+ private const float FollowLerpPerSecond = 8f;
+
+ /// A followed player whose position went stale is no longer followable.
+ private const long FollowStaleAfterMs = 5000;
private readonly Stopwatch _clock = Stopwatch.StartNew();
private CameraUpdateSystem _camera;
@@ -23,6 +60,15 @@ public partial class PlayerCursorSyncSystem : GameSystemBase
private long _lastLogMs;
private int _sent;
+ private float3 _lastSentFocus;
+ private float3 _lastSentEye;
+ private float _lastSentYaw;
+ private bool _everSent;
+
+ /// Where follow mode last placed the pivot, to notice the player taking over.
+ private float3 _followPivot;
+ private bool _followPivotValid;
+
protected override void OnCreate()
{
base.OnCreate();
@@ -38,11 +84,13 @@ protected override void OnUpdate()
if (service == null) return;
MultiplayerSession session = service.Session;
- if (!service.GameplaySyncReady) return;
-
- long now = _clock.ElapsedMilliseconds;
- if (now - _lastSentMs < SendIntervalMs) return;
- _lastSentMs = now;
+ if (!service.GameplaySyncReady)
+ {
+ service.StopFollowing();
+ _followPivotValid = false;
+ _everSent = false;
+ return;
+ }
if (_camera == null)
{
@@ -50,6 +98,11 @@ protected override void OnUpdate()
if (_camera == null) return;
}
+ long now = _clock.ElapsedMilliseconds;
+ CameraController controller = _camera.gamePlayController;
+
+ ApplyCameraIntent(service, controller, now);
+
// The ground focus (pivot) is where the player is looking; the eye is where
// their camera actually is, up in the air - both travel so markers can show
// height. Fall back to the raw camera position when no gameplay camera is
@@ -57,25 +110,95 @@ protected override void OnUpdate()
float3 eye = _camera.position;
float3 focus = eye;
float yaw = 0f;
- CameraController controller = _camera.gamePlayController;
if (controller != null)
{
focus = controller.pivot;
yaw = controller.rotation.y;
}
+ // Send at the moving cadence while anything actually changed, and fall back to
+ // a slow keepalive when it did not. The first send after becoming ready always
+ // goes out so a partner does not wait a second for the marker to appear.
+ bool moved = !_everSent ||
+ math.distancesq(focus, _lastSentFocus) > MovedDistanceSq ||
+ math.distancesq(eye, _lastSentEye) > MovedDistanceSq ||
+ math.abs(yaw - _lastSentYaw) > MovedYaw;
+
+ if (now - _lastSentMs < (moved ? MovingIntervalMs : IdleIntervalMs)) return;
+
+ _lastSentMs = now;
+ _lastSentFocus = focus;
+ _lastSentEye = eye;
+ _lastSentYaw = yaw;
+ _everSent = true;
+
session.SendPlayerState(focus.x, focus.y, focus.z, eye.x, eye.y, eye.z, yaw);
_sent++;
if (now - _lastLogMs >= 30000)
{
_lastLogMs = now;
- int remote = 0;
- foreach (var _ in service.RemotePlayers) remote++;
- Mod.Verbose("[MP] Cursors: sent " + _sent + " position(s)/30s; tracking " + remote + " remote player(s).");
+ Mod.Verbose("[MP] Cursors: sent " + _sent + " position(s)/30s; tracking " +
+ service.RemotePlayerCount + " remote player(s).");
_sent = 0;
}
}
}
+
+ ///
+ /// Consume a pending camera jump, then advance follow mode by one frame. Both are
+ /// no-ops without a gameplay camera, which is also the state a menu leaves behind.
+ ///
+ private void ApplyCameraIntent(MultiplayerService service, CameraController controller, long now)
+ {
+ if (controller == null)
+ {
+ _followPivotValid = false;
+ return;
+ }
+
+ float3 jump;
+ if (service.TakeCameraJump(out jump))
+ {
+ controller.pivot = jump;
+ _followPivot = jump;
+ _followPivotValid = true;
+ }
+
+ int followId = service.FollowPlayerId;
+ if (followId < 0)
+ {
+ _followPivotValid = false;
+ return;
+ }
+
+ RemotePlayer target = service.FindRemotePlayer(followId);
+ if (target == null || now - target.LastUpdateMs > FollowStaleAfterMs)
+ {
+ service.StopFollowing();
+ service.AppendSystemChat("Stopped following: that player is no longer reporting a position.");
+ _followPivotValid = false;
+ return;
+ }
+
+ // The player grabbing their own camera ends follow mode. Compare against where we
+ // put the pivot last frame, not against the target: the follow lerp never lands
+ // exactly on the target, so the target is not a fixed point to measure from.
+ if (_followPivotValid &&
+ math.distancesq((float3)controller.pivot, _followPivot) > FollowBreakDistanceSq)
+ {
+ service.StopFollowing();
+ service.AppendSystemChat("Stopped following - camera moved.");
+ _followPivotValid = false;
+ return;
+ }
+
+ float3 wanted = new float3(target.X, target.Y, target.Z);
+ float t = math.clamp(UnityEngine.Time.unscaledDeltaTime * FollowLerpPerSecond, 0.05f, 1f);
+ float3 next = math.lerp(controller.pivot, wanted, t);
+ controller.pivot = next;
+ _followPivot = next;
+ _followPivotValid = true;
+ }
}
}
From 184b33e27d4b047bb56dfe9ecbf640d228a4cb32 Mon Sep 17 00:00:00 2001
From: Rollocraft <70059106+Rollocraft@users.noreply.github.com>
Date: Sat, 29 Aug 2026 09:01:22 +0000
Subject: [PATCH 3/7] Add map pings, camera navigation and host lobby controls
(protocol v51)
The co-op features from #18 that the codebase genuinely does not already
have, rebuilt to fit it. Original idea and first implementation by
t1garbiznisbrate-ship-it.
A map ping is a transient "look here" beacon with an optional note, drawn as
a ring that grows and fades over six seconds in the sender's cursor colour.
It travels as command id 29 rather than as chat text. #18 encoded it into a
chat line ("/ping x y z id label") and parsed it back out on receipt, which
means the author of the marker is whoever typed the line - anyone could drop
a ping signed with another player's name - and every chat line in the session
had to be tested for it. As a command the sender comes from the message
envelope the session already authenticates, and the coordinates go through
WireGuard like every other command's do.
A ping mutates nothing, so it is deliberately outside the sync pipeline: no
echo guard, no snapshot, no resync on loss. A ping that does not arrive is a
ping nobody saw, and that is the whole failure mode. The sender records its
own ring at send time rather than waiting for the echo, because a host is
notified of its own commands and a client is not - relying on the echo would
draw the host's pings and silently swallow every client's.
Pings are not gated on ShowPartnerMarkers. That setting hides the ambient
cursor rings; someone who turned those off still wants to see a partner
deliberately pointing at something.
/goto , /goto ping, /follow and /unfollow move the camera. Chat
commands hold no camera reference and have no business taking one, so they
record an intent on the service and PlayerCursorSyncSystem - which already
owns the CameraUpdateSystem reference - carries it out. Name resolution takes
an exact id, then an exact name, then a unique prefix; an ambiguous prefix
resolves to nothing, because following the wrong partner is worse than being
asked to be more specific.
/lock and /unlock refuse new joins without ending the session; everyone
already connected stays. /banlist and /unban make the existing per-session
address bans visible and reversible, which matters mostly for undoing a ban
placed on the wrong player. All four are host-only and say so when they are
not. The lock clears when hosting ends, alongside the ban list.
/help lists what exists, and /clear clears the local log only and says so.
An unrecognised slash word is not claimed and goes out as ordinary chat, so a
typo is visible rather than silently swallowed.
Co-Authored-By: t1garbiznisbrate-ship-it <257155523+t1garbiznisbrate-ship-it@users.noreply.github.com>
---
.../Core/Protocol/ProtocolConstants.cs | 10 +-
.../MultiplayerSession/Administration.cs | 23 ++
.../Session/MultiplayerSession/Lifecycle.cs | 1 +
.../Session/MultiplayerSession/Transport.cs | 10 +
.../GameplayCommandRegistry.cs | 2 +
.../MultiplayerService/MultiplayerService.cs | 82 +++++++
.../Game/MultiplayerService/Ui/Chat.cs | 4 +
.../MultiplayerService/Ui/ChatCommands.cs | 229 ++++++++++++++++++
.../Sync/Commands/Players/MapPingCommand.cs | 67 +++++
.../Game/Sync/Players/MapPingSystem.cs | 181 ++++++++++++++
.../Sync/Players/PlayerCursorSyncSystem.cs | 6 +-
CS2MultiplayerMod/Mod.cs | 5 +
12 files changed, 617 insertions(+), 3 deletions(-)
create mode 100644 CS2MultiplayerMod/Game/MultiplayerService/Ui/ChatCommands.cs
create mode 100644 CS2MultiplayerMod/Game/Sync/Commands/Players/MapPingCommand.cs
create mode 100644 CS2MultiplayerMod/Game/Sync/Players/MapPingSystem.cs
diff --git a/CS2MultiplayerMod/Core/Protocol/ProtocolConstants.cs b/CS2MultiplayerMod/Core/Protocol/ProtocolConstants.cs
index d6f3066..270efdb 100644
--- a/CS2MultiplayerMod/Core/Protocol/ProtocolConstants.cs
+++ b/CS2MultiplayerMod/Core/Protocol/ProtocolConstants.cs
@@ -4,7 +4,13 @@ public static class ProtocolConstants
{
///
/// Wire-format version. Bump when message layout changes to refuse handshake on mismatch.
- /// Current v50 adds a reason string to ResyncRequest. It is log text only - nothing
+ /// Current v51 adds command id 29, a map ping: a transient "look here" beacon with an
+ /// optional note. It mutates nothing, so it is never replayed, snapshotted or resynced -
+ /// a ping that does not arrive is simply a ping nobody saw. It is a command rather than a
+ /// chat line so that the sender's identity comes from the message envelope the session
+ /// already authenticates; encoded into chat, anyone could drop a marker signed with
+ /// another player's name.
+ /// v50 adds a reason string to ResyncRequest. It is log text only - nothing
/// branches on it - but without it the host's log cannot tell a player pressing the sync
/// button apart from a client whose pipeline gave up on an edit, which is the single most
/// useful distinction when reading a session that kept reloading its world.
@@ -119,7 +125,7 @@ public static class ProtocolConstants
/// islands) reattach on the receiver.
/// See and version notes in doc/internals.
///
- public const int ProtocolVersion = 50;
+ public const int ProtocolVersion = 51;
///
/// Hard cap on a single payload, guarding against corrupt length prefixes.
diff --git a/CS2MultiplayerMod/Core/Session/MultiplayerSession/Administration.cs b/CS2MultiplayerMod/Core/Session/MultiplayerSession/Administration.cs
index 5e8e7e0..06fa69a 100644
--- a/CS2MultiplayerMod/Core/Session/MultiplayerSession/Administration.cs
+++ b/CS2MultiplayerMod/Core/Session/MultiplayerSession/Administration.cs
@@ -6,6 +6,29 @@ namespace CS2MultiplayerMod.Core.Session
{
public sealed partial class MultiplayerSession
{
+ ///
+ /// Host-only: refuse new joins without ending the session. Everyone already connected
+ /// stays; only the admission path consults this. Useful once a session has the players
+ /// it wants, so a public listing does not have to be taken down to stop new arrivals.
+ ///
+ public bool IsLobbyLocked { get; set; }
+
+ /// Addresses the host has banned for this hosting session.
+ public System.Collections.Generic.IReadOnlyCollection BannedAddresses =>
+ _hostBannedAddresses;
+
+ ///
+ /// Lift one ban. Bans live only as long as the hosting session, so this exists for the
+ /// case that motivates it: banning the wrong player and needing to undo it immediately.
+ ///
+ public bool UnbanAddress(string address)
+ {
+ if (string.IsNullOrEmpty(address)) return false;
+ bool removed = _hostBannedAddresses.Remove(address.Trim());
+ if (removed) _log.Info("[security] Ban lifted for " + address.Trim() + ".");
+ return removed;
+ }
+
///
/// Host-only administrative removal. The explanation is flushed to the selected
/// client before the socket closes, so it sees a useful error instead of a generic
diff --git a/CS2MultiplayerMod/Core/Session/MultiplayerSession/Lifecycle.cs b/CS2MultiplayerMod/Core/Session/MultiplayerSession/Lifecycle.cs
index aa781dc..4c6ebfb 100644
--- a/CS2MultiplayerMod/Core/Session/MultiplayerSession/Lifecycle.cs
+++ b/CS2MultiplayerMod/Core/Session/MultiplayerSession/Lifecycle.cs
@@ -279,6 +279,7 @@ private void Stop(string detail)
_peers.Clear();
_administrativeRemovals.Clear();
_hostBannedAddresses.Clear();
+ IsLobbyLocked = false;
_blobs.Clear();
_blobTransferIds.Clear();
ClearBlobProgress();
diff --git a/CS2MultiplayerMod/Core/Session/MultiplayerSession/Transport.cs b/CS2MultiplayerMod/Core/Session/MultiplayerSession/Transport.cs
index a72e9eb..6787570 100644
--- a/CS2MultiplayerMod/Core/Session/MultiplayerSession/Transport.cs
+++ b/CS2MultiplayerMod/Core/Session/MultiplayerSession/Transport.cs
@@ -50,6 +50,16 @@ private void OnTransportConnected(ConnectionId connection, long nowUnixMs)
return;
}
+ if (IsLobbyLocked)
+ {
+ _log.Info("Refused " + connection + " (" + address +
+ "): the host has locked this session.");
+ SendTo(connection, HandshakeResponse.Reject(
+ "The host has locked this session to new players."));
+ _transport.DisconnectAfterFlush(connection);
+ return;
+ }
+
// Cap the number of sockets sitting in the pre-handshake state.
int pending = 0;
foreach (var pair in _peers)
diff --git a/CS2MultiplayerMod/Game/MultiplayerService/GameplayCommandRegistry.cs b/CS2MultiplayerMod/Game/MultiplayerService/GameplayCommandRegistry.cs
index 7d29c3c..e1a131d 100644
--- a/CS2MultiplayerMod/Game/MultiplayerService/GameplayCommandRegistry.cs
+++ b/CS2MultiplayerMod/Game/MultiplayerService/GameplayCommandRegistry.cs
@@ -25,6 +25,7 @@ internal static class GameplayCommandRegistry
VisualCustomizationCommand.Id, ColorPaletteCommand.Id,
DisasterEventCommand.Id, EntityNameCommand.Id,
GrowableLifecycleCommand.Id,
+ MapPingCommand.Id,
};
internal static void Register(MultiplayerSession session)
@@ -64,6 +65,7 @@ internal static string Name(ushort id)
case DisasterEventCommand.Id: return "disaster-event";
case EntityNameCommand.Id: return "entity-name";
case GrowableLifecycleCommand.Id: return "growable-lifecycle";
+ case MapPingCommand.Id: return "map-ping";
default: return "unknown";
}
}
diff --git a/CS2MultiplayerMod/Game/MultiplayerService/MultiplayerService.cs b/CS2MultiplayerMod/Game/MultiplayerService/MultiplayerService.cs
index 3fe6f30..b0a5f28 100644
--- a/CS2MultiplayerMod/Game/MultiplayerService/MultiplayerService.cs
+++ b/CS2MultiplayerMod/Game/MultiplayerService/MultiplayerService.cs
@@ -191,6 +191,88 @@ public bool TakeCameraJump(out float3 target)
/// Post a line into the local chat feed without sending it to anyone.
public void AppendSystemChat(string text) => AppendChatEntry(null, text);
+ // ---- Map pings ---------------------------------------------------------------
+
+ private float3 _localCameraFocus;
+ private float3 _localPing;
+ private int _localPingPending;
+
+ ///
+ /// Where the local camera is looking, republished by PlayerCursorSyncSystem each time it
+ /// sends. Chat commands need a point on the map and have no camera reference of their own.
+ ///
+ public float3 LocalCameraFocus
+ {
+ get { return _localCameraFocus; }
+ internal set { _localCameraFocus = value; }
+ }
+
+ ///
+ /// Drop a beacon at the local camera focus for everyone in the session.
+ ///
+ /// The sender's own ring is recorded locally rather than waiting for the command to come
+ /// back: a host is notified of its own commands and a client is not, so relying on the
+ /// echo would draw the host's pings and silently swallow every client's.
+ ///
+ public void SendMapPing(string label)
+ {
+ if (!GameplaySyncReady) return;
+
+ float3 at = _localCameraFocus;
+ var command = new Sync.Commands.MapPingCommand
+ {
+ X = at.x,
+ Y = at.y,
+ Z = at.z,
+ Label = Core.Protocol.WireGuard.SanitizeText(
+ label, Sync.Commands.MapPingCommand.MaxLabelLength),
+ };
+
+ try { _session.SendCommand(0, Sync.Commands.MapPingCommand.Id, command.Encode()); }
+ catch (Exception ex)
+ {
+ _log.Warn("[MP] Ping not sent: " + ex.Message);
+ return;
+ }
+
+ _localPing = at;
+ Interlocked.Exchange(ref _localPingPending, 1);
+ NotePing(at);
+
+ AppendChatEntry(null, string.IsNullOrEmpty(command.Label)
+ ? "Pinged (" + (int)at.x + ", " + (int)at.z + ")."
+ : "Pinged (" + (int)at.x + ", " + (int)at.z + "): " + command.Label);
+ }
+
+ /// Consume the local player's own pending ping. True exactly once per send.
+ public bool TakeLocalPing(out float3 position)
+ {
+ if (Interlocked.Exchange(ref _localPingPending, 0) == 0)
+ {
+ position = default(float3);
+ return false;
+ }
+ position = _localPing;
+ return true;
+ }
+
+ private float3 _lastPing;
+ private bool _hasLastPing;
+
+ /// Remember where the most recent ping landed, whoever dropped it.
+ internal void NotePing(float3 position)
+ {
+ _lastPing = position;
+ _hasLastPing = true;
+ }
+
+ /// Where the most recent ping landed, for "/goto ping".
+ public bool TryGetLastPing(out float3 position)
+ {
+ position = _lastPing;
+ return _hasLastPing;
+ }
+
/// The joining client's place in the world-handover flow.
public ClientWorldPhase WorldPhase => _phase;
diff --git a/CS2MultiplayerMod/Game/MultiplayerService/Ui/Chat.cs b/CS2MultiplayerMod/Game/MultiplayerService/Ui/Chat.cs
index 5bc1f28..bf2fc5e 100644
--- a/CS2MultiplayerMod/Game/MultiplayerService/Ui/Chat.cs
+++ b/CS2MultiplayerMod/Game/MultiplayerService/Ui/Chat.cs
@@ -18,6 +18,10 @@ public void SendChatFromUi(string text)
text = text.Trim();
if (text.Length == 0) return;
+ // Local slash commands never reach the wire. An unrecognised one is not claimed
+ // and goes out as ordinary text, so a typo is visible rather than swallowed.
+ if (TryHandleChatCommand(text)) return;
+
if (!text.Equals("/sync", StringComparison.OrdinalIgnoreCase))
{
string echo = WireGuard.SanitizeText(text, WireGuard.MaxChatLength);
diff --git a/CS2MultiplayerMod/Game/MultiplayerService/Ui/ChatCommands.cs b/CS2MultiplayerMod/Game/MultiplayerService/Ui/ChatCommands.cs
new file mode 100644
index 0000000..cbfef31
--- /dev/null
+++ b/CS2MultiplayerMod/Game/MultiplayerService/Ui/ChatCommands.cs
@@ -0,0 +1,229 @@
+using System;
+using CS2MultiplayerMod.Core.Session;
+
+namespace CS2MultiplayerMod.Game
+{
+ public sealed partial class MultiplayerService
+ {
+ ///
+ /// Slash commands typed into the chat box.
+ ///
+ /// Returns true when the line was a command and has been dealt with, in which case the
+ /// caller must not also send it as chat. An unrecognised word starting with "/" is not
+ /// claimed here - it goes out as ordinary text, because refusing it would make a typo
+ /// vanish silently, and people do type "/" mid-sentence.
+ ///
+ /// "/sync" is deliberately absent: it is handled by the session, which broadcasts the
+ /// request and reports the outcome from the host's side.
+ ///
+ private bool TryHandleChatCommand(string text)
+ {
+ if (string.IsNullOrEmpty(text) || text[0] != '/') return false;
+
+ string verb, argument;
+ SplitCommand(text, out verb, out argument);
+
+ switch (verb)
+ {
+ case "/help":
+ case "/commands":
+ ShowCommandHelp();
+ return true;
+
+ case "/clear":
+ ClearChatLog();
+ return true;
+
+ case "/ping":
+ if (!GameplaySyncReady)
+ {
+ AppendSystemChat("Ping needs a live session with the city loaded.");
+ return true;
+ }
+ SendMapPing(argument);
+ return true;
+
+ case "/goto":
+ HandleGoto(argument);
+ return true;
+
+ case "/follow":
+ HandleFollow(argument);
+ return true;
+
+ case "/unfollow":
+ if (FollowPlayerId < 0) AppendSystemChat("Not following anyone.");
+ else
+ {
+ AppendSystemChat("Stopped following " + PlayerDisplayName(FollowPlayerId) + ".");
+ StopFollowing();
+ }
+ return true;
+
+ case "/lock":
+ case "/unlock":
+ HandleLobbyLock(verb == "/lock");
+ return true;
+
+ case "/banlist":
+ HandleBanList();
+ return true;
+
+ case "/unban":
+ HandleUnban(argument);
+ return true;
+
+ default:
+ return false;
+ }
+ }
+
+ /// Split "/verb rest of the line" into a lowercased verb and its argument.
+ private static void SplitCommand(string text, out string verb, out string argument)
+ {
+ int space = text.IndexOf(' ');
+ if (space < 0)
+ {
+ verb = text.ToLowerInvariant();
+ argument = string.Empty;
+ return;
+ }
+ verb = text.Substring(0, space).ToLowerInvariant();
+ argument = text.Substring(space + 1).Trim();
+ }
+
+ private void ShowCommandHelp()
+ {
+ AppendSystemChat("Commands:");
+ AppendSystemChat(" /ping [note] drop a marker where you are looking");
+ AppendSystemChat(" /goto jump the camera to a player ('/goto ping' for the last ping)");
+ AppendSystemChat(" /follow keep the camera on a player; move your camera to stop");
+ AppendSystemChat(" /unfollow stop following");
+ AppendSystemChat(" /sync ask for a fresh copy of the city");
+ AppendSystemChat(" /clear clear this chat log (yours only)");
+ if (_session.Role == SessionRole.Host)
+ {
+ AppendSystemChat("Host only:");
+ AppendSystemChat(" /lock, /unlock refuse or allow new players");
+ AppendSystemChat(" /banlist list addresses banned this session");
+ AppendSystemChat(" /unban lift one of those bans");
+ }
+ }
+
+ private void ClearChatLog()
+ {
+ lock (_chatLock)
+ {
+ _chatLog.Clear();
+ _chatLogJson = "[]";
+ }
+ AppendSystemChat("Chat cleared. This only clears your own log.");
+ }
+
+ private void HandleGoto(string argument)
+ {
+ if (argument.Length == 0)
+ {
+ AppendSystemChat("Usage: /goto , or /goto ping.");
+ return;
+ }
+
+ if (argument.Equals("ping", StringComparison.OrdinalIgnoreCase))
+ {
+ Unity.Mathematics.float3 lastPing;
+ if (!TryGetLastPing(out lastPing))
+ {
+ AppendSystemChat("No pings yet this session.");
+ return;
+ }
+ StopFollowing();
+ RequestCameraJump(lastPing);
+ AppendSystemChat("Moved to the last ping.");
+ return;
+ }
+
+ RemotePlayer target = FindRemotePlayerByName(argument);
+ if (target == null)
+ {
+ AppendSystemChat("No player matches '" + argument + "'. Try their exact name or id.");
+ return;
+ }
+
+ StopFollowing();
+ RequestCameraJump(new Unity.Mathematics.float3(target.X, target.Y, target.Z));
+ AppendSystemChat("Moved to " + PlayerDisplayName(target.PlayerId) + ".");
+ }
+
+ private void HandleFollow(string argument)
+ {
+ if (argument.Length == 0)
+ {
+ AppendSystemChat("Usage: /follow .");
+ return;
+ }
+
+ RemotePlayer target = FindRemotePlayerByName(argument);
+ if (target == null)
+ {
+ AppendSystemChat("No player matches '" + argument + "'. Try their exact name or id.");
+ return;
+ }
+
+ RequestCameraJump(new Unity.Mathematics.float3(target.X, target.Y, target.Z));
+ StartFollowing(target.PlayerId);
+ AppendSystemChat("Following " + PlayerDisplayName(target.PlayerId) +
+ ". Move your camera to stop.");
+ }
+
+ private void HandleLobbyLock(bool locked)
+ {
+ if (_session.Role != SessionRole.Host)
+ {
+ AppendSystemChat("Only the host can lock the session.");
+ return;
+ }
+ if (_session.IsLobbyLocked == locked)
+ {
+ AppendSystemChat(locked ? "Already locked." : "Already unlocked.");
+ return;
+ }
+ _session.IsLobbyLocked = locked;
+ AppendSystemChat(locked
+ ? "Locked. New players are refused; everyone already here stays connected."
+ : "Unlocked. New players can join again.");
+ }
+
+ private void HandleBanList()
+ {
+ if (_session.Role != SessionRole.Host)
+ {
+ AppendSystemChat("Only the host holds the ban list.");
+ return;
+ }
+ var bans = _session.BannedAddresses;
+ if (bans.Count == 0)
+ {
+ AppendSystemChat("No addresses banned this session.");
+ return;
+ }
+ AppendSystemChat("Banned this session: " + string.Join(", ", bans));
+ }
+
+ private void HandleUnban(string argument)
+ {
+ if (_session.Role != SessionRole.Host)
+ {
+ AppendSystemChat("Only the host holds the ban list.");
+ return;
+ }
+ if (argument.Length == 0)
+ {
+ AppendSystemChat("Usage: /unban . Use /banlist to see them.");
+ return;
+ }
+ AppendSystemChat(_session.UnbanAddress(argument)
+ ? "Unbanned " + argument + "."
+ : "'" + argument + "' is not in the ban list.");
+ }
+ }
+}
diff --git a/CS2MultiplayerMod/Game/Sync/Commands/Players/MapPingCommand.cs b/CS2MultiplayerMod/Game/Sync/Commands/Players/MapPingCommand.cs
new file mode 100644
index 0000000..4e9185d
--- /dev/null
+++ b/CS2MultiplayerMod/Game/Sync/Commands/Players/MapPingCommand.cs
@@ -0,0 +1,67 @@
+using CS2MultiplayerMod.Core.Protocol;
+using CS2MultiplayerMod.Core.Sync;
+
+namespace CS2MultiplayerMod.Game.Sync.Commands
+{
+ ///
+ /// "Look here." A transient beacon a player drops on the map for the others, with an
+ /// optional short note. It changes nothing in the city, so it is never replayed, never
+ /// snapshotted, and losing one is not a reason to do anything at all.
+ ///
+ /// It travels as a command rather than as chat text so the sender's identity comes from
+ /// the message envelope the session already authenticates. A ping encoded into a chat line
+ /// would be authored by whoever typed it, which means anyone could drop a marker signed
+ /// with someone else's name - and every chat line would have to be parsed to find out
+ /// whether it was one.
+ ///
+ public sealed class MapPingCommand : ISimulationCommand
+ {
+ public const ushort Id = 29;
+
+ /// Free text the sender typed; sanitized rather than rejected, like chat.
+ public const int MaxLabelLength = 48;
+
+ public const int MaxEncodedBytes = 128;
+
+ public float X, Y, Z;
+ public string Label;
+
+ public ushort CommandId => Id;
+
+ public void Write(NetworkWriter writer)
+ {
+ writer.WriteFloat(X);
+ writer.WriteFloat(Y);
+ writer.WriteFloat(Z);
+ writer.WriteString(Label ?? string.Empty);
+ }
+
+ public void Read(NetworkReader reader)
+ {
+ X = WireGuard.ReadCoordinate(reader);
+ Y = WireGuard.ReadCoordinate(reader);
+ Z = WireGuard.ReadCoordinate(reader);
+ Label = WireGuard.SanitizeText(reader.ReadString(), MaxLabelLength);
+ if (reader.Remaining != 0)
+ throw new ProtocolException("Trailing bytes in map-ping command.");
+ }
+
+ public byte[] Encode()
+ {
+ var writer = new NetworkWriter(MaxEncodedBytes);
+ Write(writer);
+ if (writer.Length > MaxEncodedBytes)
+ throw new ProtocolException("Map-ping command exceeds its size limit.");
+ return writer.ToArray();
+ }
+
+ public static MapPingCommand Decode(byte[] body)
+ {
+ if (body == null || body.Length > MaxEncodedBytes)
+ throw new ProtocolException("Map-ping command exceeds its size limit.");
+ var command = new MapPingCommand();
+ command.Read(new NetworkReader(body));
+ return command;
+ }
+ }
+}
diff --git a/CS2MultiplayerMod/Game/Sync/Players/MapPingSystem.cs b/CS2MultiplayerMod/Game/Sync/Players/MapPingSystem.cs
new file mode 100644
index 0000000..635b4ec
--- /dev/null
+++ b/CS2MultiplayerMod/Game/Sync/Players/MapPingSystem.cs
@@ -0,0 +1,181 @@
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using Game;
+using Game.Rendering;
+using Unity.Jobs;
+using Unity.Mathematics;
+using UnityEngine;
+using CS2MultiplayerMod.Core.Protocol.Messages;
+using CS2MultiplayerMod.Core.Session;
+using CS2MultiplayerMod.Game.Sync.Commands;
+using CS2MultiplayerMod.Game.Sync.Infrastructure;
+
+namespace CS2MultiplayerMod.Game.Sync.Players
+{
+ ///
+ /// Receives s and draws each as an expanding ring on the ground
+ /// for a few seconds, in the sender's palette colour. Sending is done from the chat command;
+ /// this system is the receiving and drawing half.
+ ///
+ /// A ping is deliberately outside the sync pipeline. It mutates nothing, so it needs no echo
+ /// guard, no resync on loss and no place in a snapshot - a ping that does not arrive is a
+ /// ping that was not seen, and that is the whole of the failure mode. The one thing it does
+ /// need is the sender's real identity, which is why it rides the command channel: the
+ /// envelope's OriginPlayerId is set by the session, not by whoever typed the text.
+ ///
+ public partial class MapPingSystem : GameSystemBase
+ {
+ /// How long a ping stays on screen.
+ private const long LifetimeMs = 6000;
+
+ /// Ring size at birth and at expiry, in metres.
+ private const float StartDiameter = 20f;
+ private const float EndDiameter = 140f;
+ private const float OutlineWidth = 5f;
+
+ /// More than this many live at once and the oldest is dropped.
+ private const int MaxActive = 24;
+
+ // Same palette and indexing as the partner markers, so a player's ping is the colour
+ // their cursor already is.
+ private static readonly Color[] Palette =
+ {
+ new Color(0.36f, 0.78f, 1.00f), // blue
+ new Color(1.00f, 0.69f, 0.26f), // orange
+ new Color(0.56f, 0.88f, 0.55f), // green
+ new Color(1.00f, 0.45f, 0.45f), // red
+ new Color(0.80f, 0.60f, 1.00f), // purple
+ new Color(1.00f, 0.85f, 0.40f), // yellow
+ };
+
+ private readonly ConcurrentQueue _incoming =
+ new ConcurrentQueue();
+ private readonly List _active = new List();
+
+ private OverlayRenderSystem _overlay;
+ private CommandObserver _observer;
+
+ private struct ActivePing
+ {
+ public float3 Position;
+ public int PlayerId;
+ public long ExpiresAtMs;
+ }
+
+ protected override void OnCreate()
+ {
+ base.OnCreate();
+ _overlay = World.GetOrCreateSystemManaged();
+ _observer = SyncObserverBinding.Bind(
+ () => new CommandObserver(_incoming, MapPingCommand.Id), DrainQueue);
+ Mod.log.Info(nameof(MapPingSystem) + " ready.");
+ }
+
+ protected override void OnDestroy()
+ {
+ SyncObserverBinding.Unbind(_observer, DrainQueue);
+ base.OnDestroy();
+ }
+
+ private void DrainQueue()
+ {
+ SyncInbox.Clear(_incoming);
+ _active.Clear();
+ }
+
+ protected override void OnUpdate()
+ {
+ MultiplayerService service = Mod.Service;
+ if (service == null) return;
+
+ if (!service.GameplaySyncReady)
+ {
+ DrainQueue();
+ return;
+ }
+
+ long now = service.NowMs;
+
+ float3 own;
+ if (service.TakeLocalPing(out own)) Add(own, service.Session.LocalPlayerId, now);
+
+ ApplyIncoming(service, now);
+ Expire(now);
+
+ // Not gated on ShowPartnerMarkers: that setting hides the ambient cursor rings, and
+ // someone who turned those off still wants to see a partner deliberately saying
+ // "look here". A ping is an event with an author, not background presence.
+ if (_active.Count == 0 || _overlay == null) return;
+
+ // Taking the buffer turns the overlay pass on for the frame and blocks on everything
+ // it depends on, so it is only taken once there is something to draw.
+ JobHandle dependencies;
+ OverlayRenderSystem.Buffer buffer = _overlay.GetBuffer(out dependencies);
+ dependencies.Complete();
+
+ for (int i = 0; i < _active.Count; i++)
+ {
+ ActivePing ping = _active[i];
+ // 0 at birth, 1 at expiry: the ring grows and fades over its life so a ping
+ // reads as an event rather than as one more permanent marker on the map.
+ float age = 1f - math.saturate((ping.ExpiresAtMs - now) / (float)LifetimeMs);
+ float diameter = math.lerp(StartDiameter, EndDiameter, age);
+
+ Color color = Palette[((ping.PlayerId % Palette.Length) + Palette.Length) % Palette.Length];
+ color.a = 0.9f * (1f - age);
+ Color fill = new Color(color.r, color.g, color.b, 0.10f * (1f - age));
+
+ buffer.DrawCircle(color, fill, OutlineWidth, default,
+ new float2(0f, 1f), ping.Position, diameter);
+ }
+ }
+
+ private void ApplyIncoming(MultiplayerService service, long now)
+ {
+ MultiplayerSession session = service.Session;
+ SimulationCommandMessage message;
+ while (_incoming.TryDequeue(out message))
+ {
+ MapPingCommand command;
+ try { command = MapPingCommand.Decode(message.Body); }
+ catch (System.Exception ex)
+ {
+ Mod.log.Warn("[MP] MapPing: dropping malformed ping: " + ex.Message);
+ continue;
+ }
+
+ // A host is notified of its own commands; the sender's own ring was already
+ // recorded at send time, so skip the echo rather than drawing it twice.
+ if (message.OriginPlayerId == session.LocalPlayerId) continue;
+
+ var at = new float3(command.X, command.Y, command.Z);
+ Add(at, message.OriginPlayerId, now);
+ service.NotePing(at); // "/goto ping" follows the newest one, whoever dropped it
+
+ string who = service.PlayerDisplayName(message.OriginPlayerId);
+ string where = "(" + (int)command.X + ", " + (int)command.Z + ")";
+ service.AppendSystemChat(string.IsNullOrEmpty(command.Label)
+ ? who + " pinged " + where + "."
+ : who + " pinged " + where + ": " + command.Label);
+ }
+ }
+
+ /// Record a ping so it can be drawn. Also used for the local player's own.
+ public void Add(float3 position, int playerId, long nowMs)
+ {
+ if (_active.Count >= MaxActive) _active.RemoveAt(0);
+ _active.Add(new ActivePing
+ {
+ Position = position,
+ PlayerId = playerId,
+ ExpiresAtMs = nowMs + LifetimeMs,
+ });
+ }
+
+ private void Expire(long now)
+ {
+ for (int i = _active.Count - 1; i >= 0; i--)
+ if (_active[i].ExpiresAtMs <= now) _active.RemoveAt(i);
+ }
+ }
+}
diff --git a/CS2MultiplayerMod/Game/Sync/Players/PlayerCursorSyncSystem.cs b/CS2MultiplayerMod/Game/Sync/Players/PlayerCursorSyncSystem.cs
index 4e274f5..36b66c9 100644
--- a/CS2MultiplayerMod/Game/Sync/Players/PlayerCursorSyncSystem.cs
+++ b/CS2MultiplayerMod/Game/Sync/Players/PlayerCursorSyncSystem.cs
@@ -16,7 +16,7 @@ namespace CS2MultiplayerMod.Game.Sync.Players
/// This system also owns every camera move the mod makes, because it holds the only
/// reference to . The chat commands do not touch the
/// camera themselves; they record an intent on
- /// ( and
+ /// ( and
/// ) and this system consumes it on the
/// next frame, on the thread that may safely do so.
///
@@ -132,6 +132,10 @@ protected override void OnUpdate()
_lastSentYaw = yaw;
_everSent = true;
+ // Chat commands need a point on the map and hold no camera reference; this is
+ // the only place that has one.
+ service.LocalCameraFocus = focus;
+
session.SendPlayerState(focus.x, focus.y, focus.z, eye.x, eye.y, eye.z, yaw);
_sent++;
diff --git a/CS2MultiplayerMod/Mod.cs b/CS2MultiplayerMod/Mod.cs
index 799993c..fb02d91 100644
--- a/CS2MultiplayerMod/Mod.cs
+++ b/CS2MultiplayerMod/Mod.cs
@@ -188,6 +188,11 @@ public void OnLoad(UpdateSystem updateSystem)
// Renders the other players' camera positions as ground rings. Rendering phase
// so the markers draw every frame, in every state (including paused).
updateSystem.UpdateAt(SystemUpdatePhase.Rendering);
+ // Draws incoming map pings, and receives them - the beacon is a command, so the
+ // observer has to be attached even in the frames where nothing is on screen.
+ // Rendering phase for the same reason as the markers above: pings must appear
+ // while the game is paused, which is exactly when players stop to point at things.
+ updateSystem.UpdateAt(SystemUpdatePhase.Rendering);
// UIUpdate, not GameSimulation: policies can be toggled while the game is paused
// (the policies panel works paused - the game routes the change through an event
// entity consumed by the every-frame modification pipeline), but the GameSimulation
From a2a51cc2c80fd2daf58d97ea1dbef69684745e87 Mon Sep 17 00:00:00 2001
From: Rollocraft <70059106+Rollocraft@users.noreply.github.com>
Date: Sat, 29 Aug 2026 09:02:16 +0000
Subject: [PATCH 4/7] Say when a world sync starts, finishes, or had nobody to
sync with
Adapted from t1garbiznisbrate-ship-it's work in #18.
Pressing sync as a host with nobody connected opened an epoch, found no
participants and closed it again, in silence. From the button that was
indistinguishable from a sync that had failed. It now says there is nobody to
sync with, and the host log records the participant count either way.
Completion was equally quiet on both sides. The host watched its own
simulation stop and start again with no explanation, and a client watched its
world reload and then had to infer from the clock moving that the handover
was over. Each now gets a line when it finishes - the client as it installs
the snapshot, the host once every participant has resumed. NotifyChat is
internal rather than private so the world-sync flow, which lives in the game
layer, reports through the same path the session's own notices use.
SanitizeSpeed had no upper bound. The resume speed arrives over the wire, and
the game's own selector tops out at 3, so a peer sending a large finite value
would have had the simulation resume at it. Clamped to a generous 8.
Co-Authored-By: t1garbiznisbrate-ship-it <257155523+t1garbiznisbrate-ship-it@users.noreply.github.com>
---
.../Core/Session/MultiplayerSession/Messaging.cs | 9 +++++++--
.../MultiplayerSession/MultiplayerSession.cs | 9 +++++++++
.../Core/Session/MultiplayerSession/Notify.cs | 7 ++++++-
.../MultiplayerService/WorldTransfer/WorldSync.cs | 15 ++++++++++++++-
.../Game/Sync/Systems/World/WorldResyncSystem.cs | 4 ++++
5 files changed, 40 insertions(+), 4 deletions(-)
diff --git a/CS2MultiplayerMod/Core/Session/MultiplayerSession/Messaging.cs b/CS2MultiplayerMod/Core/Session/MultiplayerSession/Messaging.cs
index 5f3a502..56039fb 100644
--- a/CS2MultiplayerMod/Core/Session/MultiplayerSession/Messaging.cs
+++ b/CS2MultiplayerMod/Core/Session/MultiplayerSession/Messaging.cs
@@ -152,9 +152,14 @@ public void RequestWorldSync(string reason = null)
}
else if (Role == SessionRole.Host)
{
- _log.Info("Host requested world sync for all clients (" + reason + ").");
+ // With nobody connected the epoch opens, finds no participants and closes again,
+ // which from the button looked identical to a sync that had failed silently.
+ int peers = HandshakedPeerCount();
+ _log.Info("Host requested world sync for " + peers + " client(s) (" + reason + ").");
NotifyResyncRequested(LocalPlayerId, ConnectionId.None);
- NotifyChat(null, "World sync started - streaming the city to all players.");
+ NotifyChat(null, peers == 0
+ ? "Nothing to sync - no other players are connected."
+ : "World sync started - streaming the city to all players.");
}
}
diff --git a/CS2MultiplayerMod/Core/Session/MultiplayerSession/MultiplayerSession.cs b/CS2MultiplayerMod/Core/Session/MultiplayerSession/MultiplayerSession.cs
index 2aa96d8..4fca5a3 100644
--- a/CS2MultiplayerMod/Core/Session/MultiplayerSession/MultiplayerSession.cs
+++ b/CS2MultiplayerMod/Core/Session/MultiplayerSession/MultiplayerSession.cs
@@ -133,6 +133,15 @@ public MultiplayerSession(IModLogger log, MessageCodec codec = null)
public IReadOnlyCollection Peers => _peers.Values;
+ /// How many peers finished the handshake - the session's real participant count.
+ public int HandshakedPeerCount()
+ {
+ int count = 0;
+ foreach (Peer peer in _peers.Values)
+ if (peer.Handshaked) count++;
+ return count;
+ }
+
///
/// Client-only: the host acknowledged the join and it is waiting for the host to
/// approve it by hand. True between the host's HandshakePending and its accept/reject.
diff --git a/CS2MultiplayerMod/Core/Session/MultiplayerSession/Notify.cs b/CS2MultiplayerMod/Core/Session/MultiplayerSession/Notify.cs
index c4f4900..cfcde67 100644
--- a/CS2MultiplayerMod/Core/Session/MultiplayerSession/Notify.cs
+++ b/CS2MultiplayerMod/Core/Session/MultiplayerSession/Notify.cs
@@ -59,7 +59,12 @@ private void NotifyPeerLeft(Peer peer, string reason)
catch (Exception ex) { LogObserverError("OnPeerLeft", ex); }
}
- private void NotifyChat(string sender, string text)
+ ///
+ /// Post a session notice into every observer's chat feed. Internal rather than private
+ /// so the world-sync flow, which lives in the game layer, can report its own completion
+ /// through the same path the session's own notices use.
+ ///
+ internal void NotifyChat(string sender, string text)
{
for (int i = 0; i < _observers.Count; i++)
try { _observers[i].OnChatReceived(sender, text); }
diff --git a/CS2MultiplayerMod/Game/MultiplayerService/WorldTransfer/WorldSync.cs b/CS2MultiplayerMod/Game/MultiplayerService/WorldTransfer/WorldSync.cs
index cf97272..687bbe6 100644
--- a/CS2MultiplayerMod/Game/MultiplayerService/WorldTransfer/WorldSync.cs
+++ b/CS2MultiplayerMod/Game/MultiplayerService/WorldTransfer/WorldSync.cs
@@ -167,6 +167,9 @@ private void HandleWorldSyncControl(WorldSyncStage stage, long epoch, float resu
if (_worldInstallGeneration < long.MaxValue) _worldInstallGeneration++;
ResetWorldSyncState(restoreSpeed: true);
SetPhase(ClientWorldPhase.InSession);
+ // The player watched the world reload and the simulation stop; say plainly that
+ // it is over, rather than leaving them to infer it from the clock moving again.
+ _session.NotifyChat(null, "World sync complete - your city matches the host's.");
_log.Info("[MP] World sync epoch " + epoch +
" resumed after the authoritative snapshot was installed.");
Diagnostics.FlightRecorder.Note("world-sync client resumed epoch=" + epoch);
@@ -290,7 +293,17 @@ private void ResetWorldSyncState(bool restoreSpeed)
}
}
+ ///
+ /// The speed to restore once a sync finishes. It arrives over the wire, so the upper
+ /// bound matters as much as the lower one: the game's own selector tops out at 3, and a
+ /// peer that sends a large finite value would otherwise have the simulation resume at it.
+ ///
private static float SanitizeSpeed(float speed) =>
- float.IsNaN(speed) || float.IsInfinity(speed) || speed < 0f ? 0f : speed;
+ float.IsNaN(speed) || float.IsInfinity(speed) || speed < 0f
+ ? 0f
+ : Math.Min(speed, MaxResumeSpeed);
+
+ /// Generous ceiling on a restored speed - well above the selector's 3.
+ private const float MaxResumeSpeed = 8f;
}
}
diff --git a/CS2MultiplayerMod/Game/Sync/Systems/World/WorldResyncSystem.cs b/CS2MultiplayerMod/Game/Sync/Systems/World/WorldResyncSystem.cs
index df5c558..eaa9082 100644
--- a/CS2MultiplayerMod/Game/Sync/Systems/World/WorldResyncSystem.cs
+++ b/CS2MultiplayerMod/Game/Sync/Systems/World/WorldResyncSystem.cs
@@ -363,6 +363,10 @@ private void CompleteEpoch(MultiplayerService service, MultiplayerSession sessio
// Resume-before-command order on every TCP connection.
session.ResumeWorldSync(_epoch, _resumeSpeed, targets);
service.CompleteHostWorldSync(_epoch, _resumeSpeed);
+ // The host saw its own simulation stop and start again with no explanation; the
+ // clients each get their own completion notice as they install the snapshot.
+ session.NotifyChat(null, "World sync complete - " + targets.Count +
+ (targets.Count == 1 ? " player is" : " players are") + " in sync.");
Mod.log.Info("[MP] World sync epoch " + _epoch + " completed for " +
targets.Count + " participant(s).");
ResetEpoch(now);
From 8c364d785def990b9a29b890446fbdf96383d9ae Mon Sep 17 00:00:00 2001
From: Rollocraft <70059106+Rollocraft@users.noreply.github.com>
Date: Sat, 29 Aug 2026 09:03:52 +0000
Subject: [PATCH 5/7] Report the worst peer's jitter beside the latency spread
RecordRttSample already computes the mean deviation; nothing showed it. The
periodic session line now carries min/avg/max of the smoothed round-trip plus
the worst peer's variation. A link reads as fine on the average right up until
the variation is what is hurting it, and that is the number a session that
feels like it is stuttering is actually about.
---
CS2MultiplayerMod/Game/MultiplayerSystem.cs | 8 +++++++-
CS2MultiplayerMod/Game/Sync/Players/MapPingSystem.cs | 2 +-
2 files changed, 8 insertions(+), 2 deletions(-)
diff --git a/CS2MultiplayerMod/Game/MultiplayerSystem.cs b/CS2MultiplayerMod/Game/MultiplayerSystem.cs
index 06ea147..5296760 100644
--- a/CS2MultiplayerMod/Game/MultiplayerSystem.cs
+++ b/CS2MultiplayerMod/Game/MultiplayerSystem.cs
@@ -129,6 +129,7 @@ private void WriteHealth(MultiplayerService service, MultiplayerSession session,
int pendingPeers = 0;
int latencyMin = int.MaxValue;
int latencyMax = -1;
+ int jitterMax = 0;
long latencyTotal = 0;
int latencySamples = 0;
long oldestPeerAge = 0;
@@ -146,6 +147,7 @@ private void WriteHealth(MultiplayerService service, MultiplayerSession session,
if (peer.LatencyMs < latencyMin) latencyMin = peer.LatencyMs;
if (peer.LatencyMs > latencyMax) latencyMax = peer.LatencyMs;
latencyTotal += peer.LatencyMs;
+ if (peer.JitterMs > jitterMax) jitterMax = peer.JitterMs;
latencySamples++;
}
long age = now - peer.LastSeenUnixMs;
@@ -159,9 +161,13 @@ private void WriteHealth(MultiplayerService service, MultiplayerSession session,
try { gameLoading = GameManager.instance != null && GameManager.instance.isGameLoading; }
catch { }
+ // min/avg/max of the smoothed round-trip, then the worst peer's variation. A link
+ // reads as fine on the average right up until the variation is what is hurting it,
+ // and that is the number a stuttering session is actually about.
string latency = latencySamples == 0
? "?"
- : latencyMin + "/" + (latencyTotal / latencySamples) + "/" + latencyMax;
+ : latencyMin + "/" + (latencyTotal / latencySamples) + "/" + latencyMax +
+ " +/-" + jitterMax;
string incomingChannel = string.IsNullOrEmpty(session.IncomingBlobChannel)
? "none"
: session.IncomingBlobChannel;
diff --git a/CS2MultiplayerMod/Game/Sync/Players/MapPingSystem.cs b/CS2MultiplayerMod/Game/Sync/Players/MapPingSystem.cs
index 635b4ec..5457fb4 100644
--- a/CS2MultiplayerMod/Game/Sync/Players/MapPingSystem.cs
+++ b/CS2MultiplayerMod/Game/Sync/Players/MapPingSystem.cs
@@ -161,7 +161,7 @@ private void ApplyIncoming(MultiplayerService service, long now)
}
/// Record a ping so it can be drawn. Also used for the local player's own.
- public void Add(float3 position, int playerId, long nowMs)
+ private void Add(float3 position, int playerId, long nowMs)
{
if (_active.Count >= MaxActive) _active.RemoveAt(0);
_active.Add(new ActivePing
From 8c2245c03a625a23ce3a111633455cc2074cba02 Mon Sep 17 00:00:00 2001
From: Rollocraft <70059106+Rollocraft@users.noreply.github.com>
Date: Sun, 30 Aug 2026 02:38:52 +0000
Subject: [PATCH 6/7] Replicate transport line ticket prices (protocol v52)
A line's ticket price lives on its runtime TransportLine component, not in its
Policy buffer, so PolicySyncSystem never saw it and no state channel carried
it. Everything else about a line already replicates - geometry, stops, colour,
name - which is what made the gap easy to miss: the two cities look identical
and then disagree about fare revenue, and that disagreement compounds every
transport tick, surfacing as budget drift rather than as anything visible on
the map.
Shape follows PolicySyncSystem. A 1 Hz scan diffs each line's price against
what this machine last saw and sends only what changed; prices move when a
player drags a slider, so watching every frame would buy nothing. The first
ready tick seeds the baseline rather than sending it, because the prices in a
freshly loaded save are already agreed and broadcasting them would have every
peer re-announce the whole network on join. An echo guard keyed on route
number and price stops an applied price being re-detected as a local edit.
Lines are matched by route number - the identity RouteUpdateCommand already
uses, agreed between peers because route creation replicates it - and the
prefab name is checked before writing, so a number reused by a different kind
of line cannot have a bus fare written onto a freight route. A line that has
not arrived yet is not a reason to resync: it is still in the route pipeline,
and the sender's next scan will carry the price again once it lands.
The baseline is dropped on a world reload alongside the queue. Keeping it
would have the first scan afterwards read every price as a change and
rebroadcast the entire network.
---
.../Core/Protocol/ProtocolConstants.cs | 9 +-
.../GameplayCommandRegistry.cs | 3 +-
.../Commands/Routes/TransitFareCommand.cs | 74 ++++++
.../Systems/Routes/TransitFareSyncSystem.cs | 242 ++++++++++++++++++
CS2MultiplayerMod/Mod.cs | 4 +
5 files changed, 329 insertions(+), 3 deletions(-)
create mode 100644 CS2MultiplayerMod/Game/Sync/Commands/Routes/TransitFareCommand.cs
create mode 100644 CS2MultiplayerMod/Game/Sync/Systems/Routes/TransitFareSyncSystem.cs
diff --git a/CS2MultiplayerMod/Core/Protocol/ProtocolConstants.cs b/CS2MultiplayerMod/Core/Protocol/ProtocolConstants.cs
index 270efdb..8007754 100644
--- a/CS2MultiplayerMod/Core/Protocol/ProtocolConstants.cs
+++ b/CS2MultiplayerMod/Core/Protocol/ProtocolConstants.cs
@@ -4,7 +4,12 @@ public static class ProtocolConstants
{
///
/// Wire-format version. Bump when message layout changes to refuse handshake on mismatch.
- /// Current v51 adds command id 29, a map ping: a transient "look here" beacon with an
+ /// Current v52 adds command id 30, a transport line's ticket price. The price lives on the
+ /// line's runtime TransportLine component rather than in its Policy buffer, so the policy
+ /// scan never saw it and no state channel carried it: geometry, stops, colour and name all
+ /// replicated while the two cities disagreed about fare revenue, which compounds every
+ /// transport tick and shows up as budget drift rather than as anything on the map.
+ /// v51 adds command id 29, a map ping: a transient "look here" beacon with an
/// optional note. It mutates nothing, so it is never replayed, snapshotted or resynced -
/// a ping that does not arrive is simply a ping nobody saw. It is a command rather than a
/// chat line so that the sender's identity comes from the message envelope the session
@@ -125,7 +130,7 @@ public static class ProtocolConstants
/// islands) reattach on the receiver.
/// See and version notes in doc/internals.
///
- public const int ProtocolVersion = 51;
+ public const int ProtocolVersion = 52;
///
/// Hard cap on a single payload, guarding against corrupt length prefixes.
diff --git a/CS2MultiplayerMod/Game/MultiplayerService/GameplayCommandRegistry.cs b/CS2MultiplayerMod/Game/MultiplayerService/GameplayCommandRegistry.cs
index e1a131d..0611cbc 100644
--- a/CS2MultiplayerMod/Game/MultiplayerService/GameplayCommandRegistry.cs
+++ b/CS2MultiplayerMod/Game/MultiplayerService/GameplayCommandRegistry.cs
@@ -25,7 +25,7 @@ internal static class GameplayCommandRegistry
VisualCustomizationCommand.Id, ColorPaletteCommand.Id,
DisasterEventCommand.Id, EntityNameCommand.Id,
GrowableLifecycleCommand.Id,
- MapPingCommand.Id,
+ MapPingCommand.Id, TransitFareCommand.Id,
};
internal static void Register(MultiplayerSession session)
@@ -66,6 +66,7 @@ internal static string Name(ushort id)
case EntityNameCommand.Id: return "entity-name";
case GrowableLifecycleCommand.Id: return "growable-lifecycle";
case MapPingCommand.Id: return "map-ping";
+ case TransitFareCommand.Id: return "transit-fare";
default: return "unknown";
}
}
diff --git a/CS2MultiplayerMod/Game/Sync/Commands/Routes/TransitFareCommand.cs b/CS2MultiplayerMod/Game/Sync/Commands/Routes/TransitFareCommand.cs
new file mode 100644
index 0000000..0099b30
--- /dev/null
+++ b/CS2MultiplayerMod/Game/Sync/Commands/Routes/TransitFareCommand.cs
@@ -0,0 +1,74 @@
+using CS2MultiplayerMod.Core.Protocol;
+using CS2MultiplayerMod.Core.Sync;
+
+namespace CS2MultiplayerMod.Game.Sync.Commands
+{
+ ///
+ /// "This transport line now charges this much." The ticket price is a field on the line's
+ /// runtime component, not a policy, so nothing else in the mod carries it: route geometry,
+ /// stops, colour and name all replicate, and then the two cities quietly disagree about what
+ /// riding the line costs - which shows up as a growing divergence in the transport budget
+ /// rather than as anything visible on the map.
+ ///
+ /// The line is identified the way identifies one: by its
+ /// route number, which both peers already agree on because route creation replicates it. The
+ /// prefab name travels too and is checked before applying, so a number that has been reused
+ /// for a different kind of line cannot have a price written onto it.
+ ///
+ public sealed class TransitFareCommand : ISimulationCommand
+ {
+ public const ushort Id = 30;
+
+ ///
+ /// The game's own slider stops far below this; the cap exists so a forged value cannot
+ /// be written into the line, not to describe what a player can choose.
+ ///
+ public const int MaxTicketPrice = 65535;
+
+ public const int MaxEncodedBytes = 256;
+
+ public string RoutePrefabName;
+ public int RouteNumber;
+ public int TicketPrice;
+
+ public ushort CommandId => Id;
+
+ public void Write(NetworkWriter writer)
+ {
+ writer.WriteString(RoutePrefabName);
+ writer.WriteInt(RouteNumber);
+ writer.WriteInt(TicketPrice);
+ }
+
+ public void Read(NetworkReader reader)
+ {
+ RoutePrefabName = WireGuard.ReadName(reader);
+ RouteNumber = reader.ReadInt();
+ RouteWaypointIntent.ValidateRouteNumber(RouteNumber);
+ TicketPrice = reader.ReadInt();
+ if (TicketPrice < 0 || TicketPrice > MaxTicketPrice)
+ throw new ProtocolException("Transit fare " + TicketPrice +
+ " is outside [0, " + MaxTicketPrice + "].");
+ if (reader.Remaining != 0)
+ throw new ProtocolException("Trailing bytes in transit-fare command.");
+ }
+
+ public byte[] Encode()
+ {
+ var writer = new NetworkWriter(64);
+ Write(writer);
+ if (writer.Length > MaxEncodedBytes)
+ throw new ProtocolException("Transit-fare command exceeds its size limit.");
+ return writer.ToArray();
+ }
+
+ public static TransitFareCommand Decode(byte[] body)
+ {
+ if (body == null || body.Length > MaxEncodedBytes)
+ throw new ProtocolException("Transit-fare command exceeds its size limit.");
+ var command = new TransitFareCommand();
+ command.Read(new NetworkReader(body));
+ return command;
+ }
+ }
+}
diff --git a/CS2MultiplayerMod/Game/Sync/Systems/Routes/TransitFareSyncSystem.cs b/CS2MultiplayerMod/Game/Sync/Systems/Routes/TransitFareSyncSystem.cs
new file mode 100644
index 0000000..f9d11c8
--- /dev/null
+++ b/CS2MultiplayerMod/Game/Sync/Systems/Routes/TransitFareSyncSystem.cs
@@ -0,0 +1,242 @@
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using Game;
+using Game.Common;
+using Game.Prefabs;
+using Game.Routes;
+using Game.Tools;
+using Unity.Collections;
+using Unity.Entities;
+using CS2MultiplayerMod.Core.Protocol.Messages;
+using CS2MultiplayerMod.Core.Session;
+using CS2MultiplayerMod.Game.Sync.Commands;
+using CS2MultiplayerMod.Game.Sync.Infrastructure;
+
+namespace CS2MultiplayerMod.Game.Sync.Systems
+{
+ ///
+ /// Replicates each transport line's ticket price.
+ ///
+ /// The price is a field on the line's runtime component rather
+ /// than a policy, so does not see it and no state channel
+ /// carries it. Everything else about a line already replicates - geometry, stops, colour,
+ /// name - which is what makes the gap easy to miss: the two cities look identical and then
+ /// disagree about fare revenue, and that disagreement compounds every transport tick.
+ ///
+ /// Shape follows : a 1 Hz scan diffs the current prices against
+ /// what this machine last saw and sends only what changed. Prices change when a player drags
+ /// a slider, so there is nothing to gain from watching every frame, and a scan is far cheaper
+ /// than trying to hook the panel.
+ ///
+ /// The first ready tick seeds the baseline instead of sending it: the prices in a freshly
+ /// loaded save are already agreed, and broadcasting them would have every peer re-announce
+ /// the whole network on join.
+ ///
+ public partial class TransitFareSyncSystem : GameSystemBase
+ {
+ private const long ScanIntervalMs = 1000;
+
+ private readonly ConcurrentQueue _incoming =
+ new ConcurrentQueue();
+ private readonly ReplicationGuard _guard = new ReplicationGuard();
+
+ /// Last price this machine observed per route number.
+ private readonly Dictionary _known = new Dictionary();
+
+ private PrefabSystem _prefabSystem;
+ private EntityQuery _lines;
+ private CommandObserver _observer;
+ private long _lastScanMs;
+ private bool _primed;
+
+ protected override void OnCreate()
+ {
+ base.OnCreate();
+ _prefabSystem = World.GetOrCreateSystemManaged();
+
+ _lines = GetEntityQuery(new EntityQueryDesc
+ {
+ All = SyncQuery.ReadOnly(),
+ None = SyncQuery.ReadOnly(),
+ });
+
+ _observer = SyncObserverBinding.Bind(
+ () => new CommandObserver(_incoming, TransitFareCommand.Id), DrainQueue);
+ Mod.log.Info(nameof(TransitFareSyncSystem) + " ready.");
+ }
+
+ protected override void OnDestroy()
+ {
+ SyncObserverBinding.Unbind(_observer, DrainQueue);
+ base.OnDestroy();
+ }
+
+ private void DrainQueue()
+ {
+ SyncInbox.Clear(_incoming);
+ // The baseline describes a world that is no longer loaded. Keeping it would have the
+ // first scan after a reload read every price as a change and rebroadcast the network.
+ _known.Clear();
+ _primed = false;
+ _guard.Clear();
+ }
+
+ protected override void OnUpdate()
+ {
+ using (Diagnostics.SyncProfiler.Measure("TransitFare"))
+ {
+ MultiplayerService service = Mod.Service;
+ if (service == null) return;
+
+ MultiplayerSession session = service.Session;
+ if (!service.GameplaySyncReady)
+ {
+ DrainQueue();
+ return;
+ }
+
+ long now = service.NowMs;
+ _guard.Prune(now);
+
+ // Incoming first, so a price this machine is about to adopt is in the baseline
+ // before the scan diffs against it and reports it back as a local edit.
+ ApplyIncoming(session, now);
+
+ if (now - _lastScanMs < ScanIntervalMs) return;
+ _lastScanMs = now;
+
+ Scan(session, now);
+ }
+ }
+
+ private void Scan(MultiplayerSession session, long now)
+ {
+ NativeArray lines = _lines.ToEntityArray(Allocator.Temp);
+ try
+ {
+ for (int i = 0; i < lines.Length; i++)
+ {
+ Entity line = lines[i];
+ if (!EntityManager.Exists(line) ||
+ !EntityManager.HasComponent(line) ||
+ !EntityManager.HasComponent(line)) continue;
+
+ int number = EntityManager.GetComponentData(line).m_Number;
+ int price = EntityManager.GetComponentData(line).m_TicketPrice;
+
+ int previous;
+ bool seen = _known.TryGetValue(number, out previous);
+ _known[number] = price;
+
+ if (!_primed || !seen || previous == price) continue;
+ if (_guard.Consume(FareKey(number, price), now)) continue; // we applied it
+
+ string prefabName = PrefabIndex.SafeName(
+ _prefabSystem, EntityManager.GetComponentData(line).m_Prefab);
+ if (string.IsNullOrEmpty(prefabName)) continue;
+
+ var command = new TransitFareCommand
+ {
+ RoutePrefabName = prefabName,
+ RouteNumber = number,
+ TicketPrice = price,
+ };
+ session.SendCommand(0, TransitFareCommand.Id, command.Encode());
+ Mod.Verbose("[MP] TransitFare: broadcast line " + number + " at " + price + ".");
+ }
+
+ // A line deleted while we were not looking would otherwise keep its last price in
+ // the baseline forever, and a later line reusing that number would be read as a
+ // change the moment it appeared.
+ PruneMissing(lines);
+ _primed = true;
+ }
+ finally { lines.Dispose(); }
+ }
+
+ private void PruneMissing(NativeArray lines)
+ {
+ if (_known.Count == 0) return;
+
+ var live = new HashSet();
+ for (int i = 0; i < lines.Length; i++)
+ if (EntityManager.HasComponent(lines[i]))
+ live.Add(EntityManager.GetComponentData(lines[i]).m_Number);
+
+ List gone = null;
+ foreach (var pair in _known)
+ if (!live.Contains(pair.Key)) (gone ?? (gone = new List())).Add(pair.Key);
+ if (gone == null) return;
+ for (int i = 0; i < gone.Count; i++) _known.Remove(gone[i]);
+ }
+
+ private void ApplyIncoming(MultiplayerSession session, long now)
+ {
+ SimulationCommandMessage message;
+ while (_incoming.TryDequeue(out message))
+ {
+ if (message.OriginPlayerId == session.LocalPlayerId) continue;
+
+ TransitFareCommand command;
+ try { command = TransitFareCommand.Decode(message.Body); }
+ catch (System.Exception ex)
+ {
+ Mod.log.Warn("[MP] TransitFare: dropping malformed command: " + ex.Message);
+ continue;
+ }
+
+ if (!TryApply(command))
+ {
+ // Not a reason to resync: the line is on its way through the route pipeline,
+ // and its price will be picked up by the sender's next scan once it lands.
+ Mod.Verbose("[MP] TransitFare: line " + command.RouteNumber +
+ " not here yet; ignoring its fare.");
+ continue;
+ }
+
+ _guard.Mark(FareKey(command.RouteNumber, command.TicketPrice), now);
+ _known[command.RouteNumber] = command.TicketPrice;
+ Mod.Verbose("[MP] TransitFare: line " + command.RouteNumber +
+ " set to " + command.TicketPrice + " by player " +
+ message.OriginPlayerId + ".");
+ }
+ }
+
+ private bool TryApply(TransitFareCommand command)
+ {
+ NativeArray lines = _lines.ToEntityArray(Allocator.Temp);
+ try
+ {
+ for (int i = 0; i < lines.Length; i++)
+ {
+ Entity line = lines[i];
+ if (!EntityManager.Exists(line) ||
+ !EntityManager.HasComponent(line) ||
+ !EntityManager.HasComponent(line) ||
+ !EntityManager.HasComponent(line)) continue;
+
+ if (EntityManager.GetComponentData(line).m_Number !=
+ command.RouteNumber) continue;
+
+ // A route number can be reused by a different kind of line. Writing a bus
+ // fare onto a freight line would be silent and wrong, so the prefab has to
+ // agree before anything is written.
+ string prefabName = PrefabIndex.SafeName(
+ _prefabSystem, EntityManager.GetComponentData(line).m_Prefab);
+ if (!string.Equals(prefabName, command.RoutePrefabName)) continue;
+
+ TransportLine transport = EntityManager.GetComponentData(line);
+ if (transport.m_TicketPrice == (ushort)command.TicketPrice) return true;
+ transport.m_TicketPrice = (ushort)command.TicketPrice;
+ EntityManager.SetComponentData(line, transport);
+ return true;
+ }
+ }
+ finally { lines.Dispose(); }
+ return false;
+ }
+
+ private static string FareKey(int routeNumber, int price) =>
+ "fare|" + routeNumber + "|" + price;
+ }
+}
diff --git a/CS2MultiplayerMod/Mod.cs b/CS2MultiplayerMod/Mod.cs
index fb02d91..86fb6e1 100644
--- a/CS2MultiplayerMod/Mod.cs
+++ b/CS2MultiplayerMod/Mod.cs
@@ -219,6 +219,10 @@ public void OnLoad(UpdateSystem updateSystem)
updateSystem.UpdateAt(SystemUpdatePhase.ModificationEnd);
updateSystem.UpdateAt(SystemUpdatePhase.ModificationEnd);
updateSystem.UpdateAt(SystemUpdatePhase.ModificationEnd);
+ // UIUpdate, for the same reason as the policy scan: fares are dragged in a panel that
+ // works while the game is paused, and a GameSimulation-phase scan stops ticking at
+ // speed 0 - so it would neither see a change made while paused nor apply one.
+ updateSystem.UpdateAt(SystemUpdatePhase.UIUpdate);
updateSystem.UpdateAt(SystemUpdatePhase.ModificationEnd);
// ModificationEnd, after the game's event initialization at Modification2: that pass is
// what turns a bare disaster event into a placed one (position, radius, duration), and
From 5795680ec55648d55be2cd46609d9c28bbcc0274 Mon Sep 17 00:00:00 2001
From: David
Date: Mon, 31 Aug 2026 16:26:08 +0200
Subject: [PATCH 7/7] Validate the transit fare route number through
RouteCommandCodec
---
.../Game/Sync/Commands/Routes/TransitFareCommand.cs | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/CS2MultiplayerMod/Game/Sync/Commands/Routes/TransitFareCommand.cs b/CS2MultiplayerMod/Game/Sync/Commands/Routes/TransitFareCommand.cs
index 0099b30..204c57c 100644
--- a/CS2MultiplayerMod/Game/Sync/Commands/Routes/TransitFareCommand.cs
+++ b/CS2MultiplayerMod/Game/Sync/Commands/Routes/TransitFareCommand.cs
@@ -44,7 +44,7 @@ public void Read(NetworkReader reader)
{
RoutePrefabName = WireGuard.ReadName(reader);
RouteNumber = reader.ReadInt();
- RouteWaypointIntent.ValidateRouteNumber(RouteNumber);
+ RouteCommandCodec.ValidateRouteNumber(RouteNumber);
TicketPrice = reader.ReadInt();
if (TicketPrice < 0 || TicketPrice > MaxTicketPrice)
throw new ProtocolException("Transit fare " + TicketPrice +