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