Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
572c0ad
Add exact build identity to diagnostics and handshake
bastrian Sep 21, 2026
98f15eb
Document a repeatable multiplayer regression playset
bastrian Sep 21, 2026
26b6554
Classify known mods instead of blocking every mod
bastrian Sep 21, 2026
e38dc5a
Export a self-contained diagnostic bundle
bastrian Sep 21, 2026
b8f0df6
Compare active mod manifests during handshake
bastrian Sep 21, 2026
b2e4069
Report applied atomic net operations to the host
bastrian Sep 21, 2026
b4a876d
Keep a host recovery backup before world sync
bastrian Sep 21, 2026
8a25031
Require a password for public direct hosting
bastrian Sep 21, 2026
61ef1e8
Include per-peer traffic diagnostics in exports
bastrian Sep 21, 2026
4fe6d82
Absorb post-sync command bursts before disconnecting
bastrian Sep 21, 2026
8dfc73e
Classify mod compatibility by synchronization risk
bastrian Sep 21, 2026
c068912
Explain mod playset mismatch in join status
bastrian Sep 21, 2026
8ae0221
Include loaded mod versions in handshake manifest
bastrian Sep 21, 2026
12dc91e
Add loaded assembly hashes to mod manifest
bastrian Sep 21, 2026
bf6e48e
Allow CS2 toolchain from process environment
bastrian Sep 21, 2026
1a75e01
Fix build identity diagnostic compilation
bastrian Sep 21, 2026
2c1c30b
Let hosts reserve sensitive city tools
bastrian Sep 21, 2026
961c2a2
Discard stale commands after world sync resume
bastrian Sep 21, 2026
684f600
Show per-peer net operation receipt status
bastrian Sep 21, 2026
a824272
Explain mod build mismatches during join
bastrian Sep 21, 2026
8c17017
Retry failed net operations with original transaction
bastrian Sep 21, 2026
d5dd4a2
Expose peer latency and traffic in host panel
bastrian Sep 21, 2026
f8e1ea7
Recover only failed peer after net retry
bastrian Sep 21, 2026
be7224d
Test targeted peer world recovery
bastrian Sep 21, 2026
ae8efcd
Restore clean CS2 mod builds
bastrian Sep 21, 2026
eec213f
Keep unknown build identity without git
bastrian Sep 21, 2026
b2580b5
Recognize AchievementFixer as local-only
bastrian Sep 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions CS2MultiplayerMod.HoverTests/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,26 @@ static void Reject(Action action, string name)

static void CodecChecks()
{
var handshake = new HandshakeRequest(ProtocolConstants.ProtocolVersion, "test", "commit", "game",
"Player", new byte[] { 1, 2 }, new[] { "DLC" }, new[] { "Traffic", "Move It" });
var decodedHandshake = (HandshakeRequest)Codec.Decode(Codec.Encode(handshake));
Assert(decodedHandshake.ModManifest.SequenceEqual(handshake.ModManifest),
"handshake preserves the active mod manifest");
Assert(MultiplayerSession.DescribeModMismatch(new[] { "Traffic" }, new[] { "traffic" }) == null,
"mod manifest comparison is case-insensitive");
Assert(MultiplayerSession.DescribeModMismatch(new[] { "Traffic" }, new[] { "Move It" }) != null,
"mod manifest comparison names a differing playset");
string buildMismatch = MultiplayerSession.DescribeModMismatch(
new[] { "Traffic@1.2.0#abc" }, new[] { "Traffic@1.3.0#def" });
Assert(buildMismatch != null && buildMismatch.Contains("different build") &&
!buildMismatch.Contains("you are missing"),
"mod manifest comparison separates build mismatch from missing mod");
var receipt = new NetOperationReceiptMessage(3, 91, true, "committed and drained");
var decodedReceipt = (NetOperationReceiptMessage)Codec.Decode(Codec.Encode(receipt));
Assert(decodedReceipt.OriginPlayerId == 3 && decodedReceipt.OperationId == 91 &&
decodedReceipt.Applied && decodedReceipt.Detail == "committed and drained",
"net-operation receipt round trips");

foreach (PlayerHoverKind kind in Enum.GetValues<PlayerHoverKind>())
{
var shape = Shape(kind);
Expand Down Expand Up @@ -127,6 +147,12 @@ void Send(MultiplayerSession session, params PlayerHoverShape[] shapes) =>
{
host.StartHost(Config("Host")); alice.Join(Config("Alice")); bob.Join(Config("Bob"));
Pump(() => alice.Status == SessionStatus.Connected && bob.Status == SessionStatus.Connected);
ConnectionId bobConnection = host.Peers.Single(peer => peer.PlayerId == bob.LocalPlayerId).Connection;
Assert(host.RequestWorldSyncForPeer(bobConnection, "targeted-test"),
"host starts targeted peer recovery");
Pump(() => observed[0].ResyncTargets.Count == 1);
Assert(observed[0].ResyncTargets[0] == bobConnection,
"targeted recovery names only the failed peer");
Send(alice, Shape());
Pump(() => observed[0].States.Count == 1 && observed[2].States.Count == 1);
Assert(observed[1].States.Count == 0, "source must not receive its own echo");
Expand Down Expand Up @@ -168,8 +194,16 @@ void Send(MultiplayerSession session, params PlayerHoverShape[] shapes) =>
Send(alice, Shape()); Send(host, Shape());
Settle();
Assert(observed.Sum(o => o.States.Count) == count, "barrier suppresses hover");
long resumeAt = clock.ElapsedMilliseconds;
Assert(host.ResumeWorldSync(123, 1, targets), "resume barrier");
Pump(() => !alice.WorldSyncSuspended && !bob.WorldSyncSuspended);
alice.SendCommand(1, 7, new byte[] { 1 });
Settle();
Assert(observed[0].Commands.Count == 0 && observed[2].Commands.Count == 0,
"post-sync stale command is discarded");
Pump(() => clock.ElapsedMilliseconds >= resumeAt + 350);
alice.SendCommand(2, 7, new byte[] { 2 });
Pump(() => observed[0].Commands.Count == 1);
Send(alice);
Pump(() => observed[2].States.Count == 6);
Assert(observed[2].States.Last().Hover.Length == 0, "clear after reload");
Expand All @@ -180,7 +214,12 @@ void Send(MultiplayerSession session, params PlayerHoverShape[] shapes) =>
sealed class Observer : SessionObserver
{
public readonly List<PlayerStateMessage> States = new();
public readonly List<SimulationCommandMessage> Commands = new();
public readonly List<ConnectionId> ResyncTargets = new();
public override void OnPlayerStateReceived(PlayerStateMessage state) => States.Add(state);
public override void OnCommandReceived(SimulationCommandMessage command) => Commands.Add(command);
public override void OnResyncRequested(int playerId, ConnectionId connection) =>
ResyncTargets.Add(connection);
}

sealed class BackpressureTransport(ITransport inner) : ITransport
Expand Down
4 changes: 4 additions & 0 deletions CS2MultiplayerMod.Steam/CS2MultiplayerMod.Steam.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@
</PropertyGroup>

<ItemGroup>
<!--Provides net48 reference assemblies during the build. -->
<PackageReference Include="Microsoft.NETFramework.ReferenceAssemblies"
Version="1.0.3"
PrivateAssets="all" />
<!--A plain file reference rather than a ProjectReference: the mod's build is
what drives this project (its post-build target), and a project cycle would
make that impossible.-->
Expand Down
36 changes: 36 additions & 0 deletions CS2MultiplayerMod/BuildIdentity.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
using System;
using System.Reflection;

namespace CS2MultiplayerMod
{
/// <summary>Release version and source commit of this build.</summary>
internal static class BuildIdentity
{
private const string CommitKey = "CS2MP.Commit";

internal static string Commit => _commit ?? (_commit = ReadCommit());
internal static string Label => Mod.Version + "@" + Commit;

private static string _commit;

private static string ReadCommit()
{
try
{
var attributes = typeof(Mod).Assembly.GetCustomAttributes(
typeof(AssemblyMetadataAttribute));
foreach (object item in attributes)
{
AssemblyMetadataAttribute attribute = item as AssemblyMetadataAttribute;
if (attribute != null && string.Equals(attribute.Key, CommitKey,
StringComparison.Ordinal) &&
!string.IsNullOrEmpty(attribute.Value))
return attribute.Value;
}
}
catch { /* A missing metadata attribute is a valid archive build. */ }

return "unknown";
}
}
}
32 changes: 28 additions & 4 deletions CS2MultiplayerMod/CS2MultiplayerMod.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,32 @@
</PropertyGroup>
</Target>

<!-- Include the source commit when Git is available. -->
<Target Name="StampBuildCommit" BeforeTargets="GetAssemblyAttributes;GenerateAssemblyInfo">
<Exec Command="git rev-parse --short=12 HEAD"
WorkingDirectory="$(MSBuildProjectDirectory)\.."
ConsoleToMSBuild="true"
IgnoreExitCode="true">
<Output TaskParameter="ConsoleOutput" PropertyName="GitCommitOutput" />
</Exec>
<PropertyGroup>
<BuildCommit>unknown</BuildCommit>
<!-- Only accept a Git commit; otherwise use "unknown". -->
<BuildCommit Condition="$([System.Text.RegularExpressions.Regex]::IsMatch('$(GitCommitOutput)', '^[0-9a-fA-F]{7,40}\s*$'))">$([System.String]::Copy('$(GitCommitOutput)').Trim())</BuildCommit>
</PropertyGroup>
<ItemGroup>
<AssemblyMetadata Include="CS2MP.Commit" Value="$(BuildCommit)" />
</ItemGroup>
</Target>

<!-- Prefer the process environment; the installer uses the user variable. -->
<PropertyGroup>
<CsiiToolPath>$([System.Environment]::GetEnvironmentVariable('CSII_TOOLPATH'))</CsiiToolPath>
<CsiiToolPath Condition="'$(CsiiToolPath)' == ''">$([System.Environment]::GetEnvironmentVariable('CSII_TOOLPATH', 'EnvironmentVariableTarget.User'))</CsiiToolPath>
</PropertyGroup>
<!--Imports must be after PropertyGroup block-->
<Import Project="$([System.Environment]::GetEnvironmentVariable('CSII_TOOLPATH', 'EnvironmentVariableTarget.User'))\Mod.props"/>
<Import Project="$([System.Environment]::GetEnvironmentVariable('CSII_TOOLPATH', 'EnvironmentVariableTarget.User'))\Mod.targets"/>
<Import Project="$(CsiiToolPath)/Mod.props"/>
<Import Project="$(CsiiToolPath)/Mod.targets"/>

<ItemGroup>
<Reference Include="Game">
Expand Down Expand Up @@ -139,8 +162,9 @@
<PropertyGroup>
<SteamBackendDir>$(MSBuildProjectDirectory)\..\CS2MultiplayerMod.Steam</SteamBackendDir>
</PropertyGroup>
<MSBuild Projects="$(SteamBackendDir)\CS2MultiplayerMod.Steam.csproj"
Targets="Build" Properties="Configuration=$(Configuration)"/>
<!--Run the relay in a separate dotnet process. Its restore generates the net48 reference
imports, and MSBuild's in-process project cache cannot observe those on a first build. -->
<Exec Command="dotnet build &quot;$(SteamBackendDir)\CS2MultiplayerMod.Steam.csproj&quot; --configuration $(Configuration)"/>
<ItemGroup>
<SteamBackendFiles Include="$(SteamBackendDir)\bin\$(Configuration)\net48\CS2MultiplayerMod.Steam.dll"/>
<SteamBackendFiles Include="$(SteamBackendDir)\bin\$(Configuration)\net48\CS2MultiplayerMod.Steam.pdb"/>
Expand Down
5 changes: 3 additions & 2 deletions CS2MultiplayerMod/Core/Protocol/Framing/MessageCodec.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,12 @@ private struct Entry
public static MessageCodec CreateDefault()
{
var codec = new MessageCodec();
// Sized for the DLC list (≤64 entries of ≤64 chars) on top of the fixed fields.
codec.Register(MessageType.HandshakeRequest, () => new HandshakeRequest(), 32 * 1024);
// Leaves room for DLC and the active-mod manifest.
codec.Register(MessageType.HandshakeRequest, () => new HandshakeRequest(), 64 * 1024);
codec.Register(MessageType.HandshakeResponse, () => new HandshakeResponse(), 1024);
codec.Register(MessageType.HandshakeChallenge, () => new HandshakeChallenge(), 256);
codec.Register(MessageType.HandshakePending, () => new HandshakePendingMessage(), 64);
codec.Register(MessageType.NetOperationReceipt, () => new NetOperationReceiptMessage(), 512);
codec.Register(MessageType.Heartbeat, () => new Heartbeat(), 64);
codec.Register(MessageType.Chat, () => new ChatMessage(), 4 * 1024);
codec.Register(MessageType.SimulationCommand, () => new SimulationCommandMessage(),
Expand Down
3 changes: 3 additions & 0 deletions CS2MultiplayerMod/Core/Protocol/Framing/MessageType.cs
Original file line number Diff line number Diff line change
Expand Up @@ -69,5 +69,8 @@ public enum MessageType : byte
/// screen) until a <see cref="HandshakeResponse"/> accepts or rejects it.
/// </summary>
HandshakePending = 14,

/// <summary>Client -> host: outcome of locally applying an atomic net operation.</summary>
NetOperationReceipt = 15,
}
}
Original file line number Diff line number Diff line change
@@ -1,31 +1,34 @@
namespace CS2MultiplayerMod.Core.Protocol.Messages
{
/// <summary>
/// Client's answer to <see cref="HandshakeChallenge"/>. Host validates protocol,
/// builds, DLC list, and password proof first. <see cref="PasswordProof"/> is
/// HMAC-SHA256(password, nonce | channel-binding). <see cref="DlcList"/> (sorted)
/// carries sync-relevant DLC names; differing DLCs cause desync.
/// Client response to <see cref="HandshakeChallenge"/>. The host validates the
/// protocol, build, DLCs, active mods, and password proof before admitting it.
/// </summary>
public sealed class HandshakeRequest : INetMessage
{
public int ProtocolVersion;
public string ModVersion;
public string BuildId;
public string GameVersion;
public string PlayerName;
public byte[] PasswordProof;
public string[] DlcList;
public string[] ModManifest;

public HandshakeRequest() { }

public HandshakeRequest(int protocolVersion, string modVersion, string gameVersion,
string playerName, byte[] passwordProof, string[] dlcList = null)
public HandshakeRequest(int protocolVersion, string modVersion, string buildId, string gameVersion,
string playerName, byte[] passwordProof, string[] dlcList = null,
string[] modManifest = null)
{
ProtocolVersion = protocolVersion;
ModVersion = modVersion;
BuildId = buildId;
GameVersion = gameVersion;
PlayerName = playerName;
PasswordProof = passwordProof ?? System.Array.Empty<byte>();
DlcList = dlcList ?? System.Array.Empty<string>();
ModManifest = modManifest ?? System.Array.Empty<string>();
}

public MessageType Type => MessageType.HandshakeRequest;
Expand All @@ -34,6 +37,7 @@ public void Write(NetworkWriter writer)
{
writer.WriteInt(ProtocolVersion);
writer.WriteString(ModVersion);
writer.WriteString(BuildId);
writer.WriteString(GameVersion);
writer.WriteString(PlayerName);
writer.WriteInt(PasswordProof != null ? PasswordProof.Length : 0);
Expand All @@ -45,12 +49,19 @@ public void Write(NetworkWriter writer)
writer.WriteInt(dlcCount);
for (int i = 0; i < dlcCount; i++)
writer.WriteString(DlcList[i] ?? string.Empty);

int modCount = ModManifest != null ? ModManifest.Length : 0;
if (modCount > ProtocolConstants.MaxModManifestEntries) modCount = ProtocolConstants.MaxModManifestEntries;
writer.WriteInt(modCount);
for (int i = 0; i < modCount; i++)
writer.WriteString(ModManifest[i] ?? string.Empty);
}

public void Read(NetworkReader reader)
{
ProtocolVersion = reader.ReadInt();
ModVersion = reader.ReadString();
BuildId = WireGuard.SanitizeText(reader.ReadString(), 64);
GameVersion = reader.ReadString();
PlayerName = reader.ReadString();
int length = reader.ReadInt();
Expand All @@ -68,6 +79,13 @@ public void Read(NetworkReader reader)
// like any other display text instead of trusted off the wire.
DlcList[i] = WireGuard.SanitizeText(reader.ReadString(), ProtocolConstants.MaxDlcNameLength);
}

int modCount = reader.ReadInt();
if (modCount < 0 || modCount > ProtocolConstants.MaxModManifestEntries)
throw new ProtocolException("Implausible mod-manifest count: " + modCount + ".");
ModManifest = modCount > 0 ? new string[modCount] : System.Array.Empty<string>();
for (int i = 0; i < modCount; i++)
ModManifest[i] = WireGuard.SanitizeText(reader.ReadString(), ProtocolConstants.MaxModManifestNameLength);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
namespace CS2MultiplayerMod.Core.Protocol.Messages
{
/// <summary>Client -> host receipt for the local realization of one atomic net operation.</summary>
public sealed class NetOperationReceiptMessage : INetMessage
{
public int OriginPlayerId;
public long OperationId;
public bool Applied;
public string Detail;
public MessageType Type => MessageType.NetOperationReceipt;

public NetOperationReceiptMessage() { }
public NetOperationReceiptMessage(int originPlayerId, long operationId, bool applied, string detail = null)
{ OriginPlayerId = originPlayerId; OperationId = operationId; Applied = applied; Detail = detail; }
public void Write(NetworkWriter writer)
{
writer.WriteInt(OriginPlayerId); writer.WriteLong(OperationId); writer.WriteBool(Applied);
writer.WriteString(WireGuard.SanitizeText(Detail, 256));
}
public void Read(NetworkReader reader)
{
OriginPlayerId = reader.ReadInt(); OperationId = reader.ReadLong(); Applied = reader.ReadBool();
Detail = WireGuard.SanitizeText(reader.ReadString(), 256);
if (OperationId <= 0) throw new ProtocolException("Invalid net-operation receipt id.");
}
}
}
8 changes: 7 additions & 1 deletion CS2MultiplayerMod/Core/Protocol/ProtocolConstants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -214,12 +214,18 @@ public static class ProtocolConstants
/// islands) reattach on the receiver.
/// See <see cref="Messages.HandshakeRequest"/> and version notes in doc/internals.
/// </summary>
// v70 adds authenticated client receipts for applied atomic net operations.
// v69 compares the complete active-mod manifest during the handshake.
// v68 adds the source artifact id for locally-built versions.
// v67 adds the two mod-state commands: the session's third-party type table and a
// carrier's replicated closure. A v66 peer refuses both as unauthorized command ids and
// would drop the connection over state it simply predates, so the bump keeps that
// disagreement at the handshake where it can be explained.
// v66 adds bounded display-only hover geometry to player presence updates.
public const int ProtocolVersion = 67;
public const int ProtocolVersion = 70;

public const int MaxModManifestEntries = 256;
public const int MaxModManifestNameLength = 128;

/// <summary>
/// Hard cap on a single payload, guarding against corrupt length prefixes.
Expand Down
2 changes: 2 additions & 0 deletions CS2MultiplayerMod/Core/Session/Contract/ISessionObserver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ public interface ISessionObserver
void OnPeerLeft(Peer peer, string reason);
void OnChatReceived(string senderName, string text);
void OnCommandReceived(SimulationCommandMessage command);
void OnNetOperationReceipt(Peer peer, NetOperationReceiptMessage receipt);

/// <summary>A replicated state snapshot arrived (clients only). Apply it to the world.</summary>
void OnStateReceived(StateSnapshotMessage snapshot);
Expand Down Expand Up @@ -60,6 +61,7 @@ public virtual void OnPeerJoined(Peer peer) { }
public virtual void OnPeerLeft(Peer peer, string reason) { }
public virtual void OnChatReceived(string senderName, string text) { }
public virtual void OnCommandReceived(SimulationCommandMessage command) { }
public virtual void OnNetOperationReceipt(Peer peer, NetOperationReceiptMessage receipt) { }
public virtual void OnStateReceived(StateSnapshotMessage snapshot) { }
public virtual void OnStateEditReceived(StateEditMessage edit) { }
public virtual void OnPlayerStateReceived(PlayerStateMessage state) { }
Expand Down
Loading