Skip to content
66 changes: 62 additions & 4 deletions CS2MultiplayerMod/Core/Networking/Tcp/FramedConnection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,18 @@ internal sealed class FramedConnection
private Thread _readThread;
private int _closed; // 0 = open, 1 = closed (Interlocked guarded)

/// <summary>Kernel send/receive buffer size requested per socket.</summary>
private const int SocketBufferBytes = 1024 * 1024;

/// <summary>How long a peer may take over the TLS handshake before it is dropped.</summary>
private const int HandshakeTimeoutMs = 15000;

/// <summary>
/// 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.
/// </summary>
private const int SingleWriteThreshold = 8 * 1024;

/// <summary>Raised on the read thread once the connection is usable (TLS done).</summary>
public Action<ConnectionId> OnReady;

Expand All @@ -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
{
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -251,23 +296,32 @@ 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;
}

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;
}
Expand All @@ -279,7 +333,11 @@ private bool Upgrade()
/// <summary>Read exactly <paramref name="count"/> bytes; false on clean EOF.</summary>
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)
{
Expand Down
15 changes: 13 additions & 2 deletions CS2MultiplayerMod/Core/Protocol/ProtocolConstants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,18 @@ public static class ProtocolConstants
{
/// <summary>
/// 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.
Expand Down Expand Up @@ -119,7 +130,7 @@ public static class ProtocolConstants
/// islands) reattach on the receiver.
/// See <see cref="Messages.HandshakeRequest"/> and version notes in doc/internals.
/// </summary>
public const int ProtocolVersion = 50;
public const int ProtocolVersion = 52;

/// <summary>
/// Hard cap on a single payload, guarding against corrupt length prefixes.
Expand Down
21 changes: 19 additions & 2 deletions CS2MultiplayerMod/Core/Protocol/Wire/NetworkReader.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System;
using System.Runtime.InteropServices;
using System.Text;

namespace CS2MultiplayerMod.Core.Protocol
Expand Down Expand Up @@ -62,12 +63,28 @@ public long ReadLong()
return value;
}

/// <summary>
/// Mirror of <see cref="NetworkWriter.WriteFloat"/>: assemble the little-endian bits by
/// hand, then reinterpret. Replaces <c>BitConverter.ToSingle</c>, which read whatever
/// order the runtime happened to use rather than the order the writer states.
/// </summary>
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;
}

/// <summary>IEEE-754 reinterpretation without allocating. Shared shape with the writer.</summary>
[StructLayout(LayoutKind.Explicit)]
private struct FloatBits
{
[FieldOffset(0)] public float Float;
[FieldOffset(0)] public int Int;
}

public string ReadString()
Expand Down
30 changes: 26 additions & 4 deletions CS2MultiplayerMod/Core/Protocol/Wire/NetworkWriter.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System;
using System.Runtime.InteropServices;
using System.Text;

namespace CS2MultiplayerMod.Core.Protocol
Expand Down Expand Up @@ -59,12 +60,33 @@ public void WriteLong(long value)
}
}

/// <summary>
/// Reinterprets the float's bits through an overlaid int and writes them in the same
/// explicit little-endian order as <see cref="WriteInt"/>.
///
/// The previous <c>BitConverter.GetBytes</c> 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.
/// </summary>
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);
}

/// <summary>IEEE-754 reinterpretation without allocating. Shared shape with the reader.</summary>
[StructLayout(LayoutKind.Explicit)]
private struct FloatBits
{
[FieldOffset(0)] public float Float;
[FieldOffset(0)] public int Int;
}

public void WriteString(string value)
Expand Down
9 changes: 8 additions & 1 deletion CS2MultiplayerMod/Core/Session/BlobReassembler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,19 @@ namespace CS2MultiplayerMod.Core.Session
/// </summary>
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();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid preallocating the entire announced blob size

When a client receives the first map chunk, the host-controlled TotalBytes may be as large as the registered 256 MiB ceiling, so this constructor immediately reserves 256 MiB even if the chunk contains only a few bytes. A malicious or malfunctioning host can repeatedly replace incomplete transfer IDs and force large allocations much faster than data arrives, potentially terminating the game with an out-of-memory failure; retain incremental growth or otherwise delay/limit allocation based on bytes actually received.

Useful? React with 👍 / 👎.

@t1garbiznisbrate-ship-it t1garbiznisbrate-ship-it Aug 29, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fix for Blob Preallocation (Initial Capacity Ceiling)

Capping the initial buffer allocation to 4 MiB (or expectedBytes, whichever is smaller) prevents immediately reserving up to the 256 MiB channel ceiling on the Large Object Heap upon receiving the first chunk.

This directly addresses the concern where incomplete or malicious transfers could force large allocations much faster than data actually arrives:

Announced Size Initial Allocation
100 KB 100 KB
1 MB 1 MB
4 MB 4 MB
20 MB 4 MB
100 MB 4 MB
256 MB 4 MB

MemoryStream grows dynamically as subsequent chunks arrive, while maintaining the existing channel ceiling checks for total blob validation.

(Note: Add using System; if not already present, or use Math.Min).

Suggested change
_buffer = expectedBytes > 0 ? new MemoryStream(expectedBytes) : new MemoryStream();
private const int InitialBufferCapacity = 4 * 1024 * 1024; // 4 MiB
private readonly MemoryStream _buffer;
public BlobReassembler(int expectedBytes, long nowMs)
{
ExpectedBytes = expectedBytes;
LastChunkAtMs = nowMs;
int initialCapacity = expectedBytes > 0
? Math.Min(expectedBytes, InitialBufferCapacity)
: 0;
_buffer = initialCapacity > 0
? new MemoryStream(initialCapacity)
: new MemoryStream();
}

}

public int ExpectedBytes { get; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,29 @@ namespace CS2MultiplayerMod.Core.Session
{
public sealed partial class MultiplayerSession
{
/// <summary>
/// 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.
/// </summary>
public bool IsLobbyLocked { get; set; }

/// <summary>Addresses the host has banned for this hosting session.</summary>
public System.Collections.Generic.IReadOnlyCollection<string> BannedAddresses =>
_hostBannedAddresses;

/// <summary>
/// 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.
/// </summary>
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;
}

/// <summary>
/// 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,7 @@ private void Stop(string detail)
_peers.Clear();
_administrativeRemovals.Clear();
_hostBannedAddresses.Clear();
IsLobbyLocked = false;
_blobs.Clear();
_blobTransferIds.Clear();
ClearBlobProgress();
Expand Down
11 changes: 8 additions & 3 deletions CS2MultiplayerMod/Core/Session/MultiplayerSession/Messaging.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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.");
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,12 @@ public sealed partial class MultiplayerSession
/// pre-handshake socket is never held open indefinitely.</summary>
private const int JoinApprovalTimeoutMs = 120000;

private const int HostPlayerId = 1;
/// <summary>
/// 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.
/// </summary>
public const int HostPlayerId = 1;

/// <summary>Reassembling blobs allowed at once on a client.</summary>
private const int MaxActiveBlobs = 4;
Expand Down Expand Up @@ -128,6 +133,15 @@ public MultiplayerSession(IModLogger log, MessageCodec codec = null)

public IReadOnlyCollection<Peer> Peers => _peers.Values;

/// <summary>How many peers finished the handshake - the session's real participant count.</summary>
public int HandshakedPeerCount()
{
int count = 0;
foreach (Peer peer in _peers.Values)
if (peer.Handshaked) count++;
return count;
}

/// <summary>
/// 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.
Expand Down
7 changes: 6 additions & 1 deletion CS2MultiplayerMod/Core/Session/MultiplayerSession/Notify.cs
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,12 @@ private void NotifyPeerLeft(Peer peer, string reason)
catch (Exception ex) { LogObserverError("OnPeerLeft", ex); }
}

private void NotifyChat(string sender, string text)
/// <summary>
/// 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.
/// </summary>
internal void NotifyChat(string sender, string text)
{
for (int i = 0; i < _observers.Count; i++)
try { _observers[i].OnChatReceived(sender, text); }
Expand Down
10 changes: 10 additions & 0 deletions CS2MultiplayerMod/Core/Session/MultiplayerSession/Transport.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading