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/ProtocolConstants.cs b/CS2MultiplayerMod/Core/Protocol/ProtocolConstants.cs
index d6f3066..8007754 100644
--- a/CS2MultiplayerMod/Core/Protocol/ProtocolConstants.cs
+++ b/CS2MultiplayerMod/Core/Protocol/ProtocolConstants.cs
@@ -4,7 +4,18 @@ 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 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
+ /// 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 +130,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 = 52;
///
/// Hard cap on a single payload, guarding against corrupt length prefixes.
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/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/Messaging.cs b/CS2MultiplayerMod/Core/Session/MultiplayerSession/Messaging.cs
index 5b100e7..56039fb 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;
}
@@ -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 658f6c1..4fca5a3 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;
@@ -128,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/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/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/GameplayCommandRegistry.cs b/CS2MultiplayerMod/Game/MultiplayerService/GameplayCommandRegistry.cs
index 7d29c3c..0611cbc 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, TransitFareCommand.Id,
};
internal static void Register(MultiplayerSession session)
@@ -64,6 +65,8 @@ 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";
+ case TransitFareCommand.Id: return "transit-fare";
default: return "unknown";
}
}
diff --git a/CS2MultiplayerMod/Game/MultiplayerService/MultiplayerService.cs b/CS2MultiplayerMod/Game/MultiplayerService/MultiplayerService.cs
index 5c4fb6e..b0a5f28 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,187 @@ 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);
+
+ // ---- 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;
@@ -369,42 +554,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/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/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/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/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/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/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/Commands/Routes/TransitFareCommand.cs b/CS2MultiplayerMod/Game/Sync/Commands/Routes/TransitFareCommand.cs
new file mode 100644
index 0000000..204c57c
--- /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();
+ RouteCommandCodec.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/Players/MapPingSystem.cs b/CS2MultiplayerMod/Game/Sync/Players/MapPingSystem.cs
new file mode 100644
index 0000000..5457fb4
--- /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.
+ private 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 e1254c3..36b66c9 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,99 @@ 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;
+
+ // 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++;
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;
+ }
}
}
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/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/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;
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);
diff --git a/CS2MultiplayerMod/Mod.cs b/CS2MultiplayerMod/Mod.cs
index 799993c..86fb6e1 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
@@ -214,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