diff --git a/CS2MultiplayerMod.HoverTests/Program.cs b/CS2MultiplayerMod.HoverTests/Program.cs index c14bc6e..b336ab9 100644 --- a/CS2MultiplayerMod.HoverTests/Program.cs +++ b/CS2MultiplayerMod.HoverTests/Program.cs @@ -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()) { var shape = Shape(kind); @@ -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"); @@ -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"); @@ -180,7 +214,12 @@ void Send(MultiplayerSession session, params PlayerHoverShape[] shapes) => sealed class Observer : SessionObserver { public readonly List States = new(); + public readonly List Commands = new(); + public readonly List 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 diff --git a/CS2MultiplayerMod.Steam/CS2MultiplayerMod.Steam.csproj b/CS2MultiplayerMod.Steam/CS2MultiplayerMod.Steam.csproj index c6a243b..c4719ee 100644 --- a/CS2MultiplayerMod.Steam/CS2MultiplayerMod.Steam.csproj +++ b/CS2MultiplayerMod.Steam/CS2MultiplayerMod.Steam.csproj @@ -33,6 +33,10 @@ + + diff --git a/CS2MultiplayerMod/BuildIdentity.cs b/CS2MultiplayerMod/BuildIdentity.cs new file mode 100644 index 0000000..136a404 --- /dev/null +++ b/CS2MultiplayerMod/BuildIdentity.cs @@ -0,0 +1,36 @@ +using System; +using System.Reflection; + +namespace CS2MultiplayerMod +{ + /// Release version and source commit of this build. + 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"; + } + } +} diff --git a/CS2MultiplayerMod/CS2MultiplayerMod.csproj b/CS2MultiplayerMod/CS2MultiplayerMod.csproj index 312a40d..358064a 100644 --- a/CS2MultiplayerMod/CS2MultiplayerMod.csproj +++ b/CS2MultiplayerMod/CS2MultiplayerMod.csproj @@ -35,9 +35,32 @@ + + + + + + + unknown + + $([System.String]::Copy('$(GitCommitOutput)').Trim()) + + + + + + + + + $([System.Environment]::GetEnvironmentVariable('CSII_TOOLPATH')) + $([System.Environment]::GetEnvironmentVariable('CSII_TOOLPATH', 'EnvironmentVariableTarget.User')) + - - + + @@ -139,8 +162,9 @@ $(MSBuildProjectDirectory)\..\CS2MultiplayerMod.Steam - + + diff --git a/CS2MultiplayerMod/Core/Protocol/Framing/MessageCodec.cs b/CS2MultiplayerMod/Core/Protocol/Framing/MessageCodec.cs index 816bb7d..3c53e17 100644 --- a/CS2MultiplayerMod/Core/Protocol/Framing/MessageCodec.cs +++ b/CS2MultiplayerMod/Core/Protocol/Framing/MessageCodec.cs @@ -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(), diff --git a/CS2MultiplayerMod/Core/Protocol/Framing/MessageType.cs b/CS2MultiplayerMod/Core/Protocol/Framing/MessageType.cs index 577b9d8..7e4e2d1 100644 --- a/CS2MultiplayerMod/Core/Protocol/Framing/MessageType.cs +++ b/CS2MultiplayerMod/Core/Protocol/Framing/MessageType.cs @@ -69,5 +69,8 @@ public enum MessageType : byte /// screen) until a accepts or rejects it. /// HandshakePending = 14, + + /// Client -> host: outcome of locally applying an atomic net operation. + NetOperationReceipt = 15, } } diff --git a/CS2MultiplayerMod/Core/Protocol/Messages/Handshake/HandshakeRequest.cs b/CS2MultiplayerMod/Core/Protocol/Messages/Handshake/HandshakeRequest.cs index a9092e1..88778a0 100644 --- a/CS2MultiplayerMod/Core/Protocol/Messages/Handshake/HandshakeRequest.cs +++ b/CS2MultiplayerMod/Core/Protocol/Messages/Handshake/HandshakeRequest.cs @@ -1,31 +1,34 @@ namespace CS2MultiplayerMod.Core.Protocol.Messages { /// - /// Client's answer to . Host validates protocol, - /// builds, DLC list, and password proof first. is - /// HMAC-SHA256(password, nonce | channel-binding). (sorted) - /// carries sync-relevant DLC names; differing DLCs cause desync. + /// Client response to . The host validates the + /// protocol, build, DLCs, active mods, and password proof before admitting it. /// 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(); DlcList = dlcList ?? System.Array.Empty(); + ModManifest = modManifest ?? System.Array.Empty(); } public MessageType Type => MessageType.HandshakeRequest; @@ -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); @@ -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(); @@ -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(); + for (int i = 0; i < modCount; i++) + ModManifest[i] = WireGuard.SanitizeText(reader.ReadString(), ProtocolConstants.MaxModManifestNameLength); } } } diff --git a/CS2MultiplayerMod/Core/Protocol/Messages/Sync/NetOperationReceiptMessage.cs b/CS2MultiplayerMod/Core/Protocol/Messages/Sync/NetOperationReceiptMessage.cs new file mode 100644 index 0000000..96f3408 --- /dev/null +++ b/CS2MultiplayerMod/Core/Protocol/Messages/Sync/NetOperationReceiptMessage.cs @@ -0,0 +1,27 @@ +namespace CS2MultiplayerMod.Core.Protocol.Messages +{ + /// Client -> host receipt for the local realization of one atomic net operation. + 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."); + } + } +} diff --git a/CS2MultiplayerMod/Core/Protocol/ProtocolConstants.cs b/CS2MultiplayerMod/Core/Protocol/ProtocolConstants.cs index 99679d1..34dd57d 100644 --- a/CS2MultiplayerMod/Core/Protocol/ProtocolConstants.cs +++ b/CS2MultiplayerMod/Core/Protocol/ProtocolConstants.cs @@ -214,12 +214,18 @@ public static class ProtocolConstants /// islands) reattach on the receiver. /// See and version notes in doc/internals. /// + // 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; /// /// Hard cap on a single payload, guarding against corrupt length prefixes. diff --git a/CS2MultiplayerMod/Core/Session/Contract/ISessionObserver.cs b/CS2MultiplayerMod/Core/Session/Contract/ISessionObserver.cs index a241f0f..fcfb900 100644 --- a/CS2MultiplayerMod/Core/Session/Contract/ISessionObserver.cs +++ b/CS2MultiplayerMod/Core/Session/Contract/ISessionObserver.cs @@ -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); /// A replicated state snapshot arrived (clients only). Apply it to the world. void OnStateReceived(StateSnapshotMessage snapshot); @@ -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) { } diff --git a/CS2MultiplayerMod/Core/Session/Contract/MultiplayerConfig.cs b/CS2MultiplayerMod/Core/Session/Contract/MultiplayerConfig.cs index 9a5eda9..6ec18e9 100644 --- a/CS2MultiplayerMod/Core/Session/Contract/MultiplayerConfig.cs +++ b/CS2MultiplayerMod/Core/Session/Contract/MultiplayerConfig.cs @@ -51,6 +51,9 @@ public sealed class MultiplayerConfig /// Mod build identifier, normally compared strictly during the handshake. public readonly string ModVersion; + /// Exact source artifact identifier, informative but logged by both peers. + public readonly string BuildId; + /// /// Host only. Allows a different multiplayer-mod build through the handshake. /// Protocol compatibility remains mandatory and is checked before this flag is @@ -67,6 +70,9 @@ public sealed class MultiplayerConfig /// public readonly bool SimulationSync; + /// Host only: reserve destructive or city-wide tools for the host player. + public readonly bool HostOnlySensitiveTools; + /// Game build identifier, compared strictly during the handshake. public readonly string GameVersion; @@ -77,13 +83,17 @@ public sealed class MultiplayerConfig /// public readonly string[] DlcList; + /// Canonical names of every other active mod, compared during handshake. + public readonly string[] ModManifest; + public MultiplayerConfig(string playerName, string hostAddress, int port, string password = "", bool lanOnly = true, bool useEncryption = true, int maxPlayers = 8, string modVersion = "", string gameVersion = "", string[] dlcList = null, bool requireJoinApproval = false, TransportMode transport = TransportMode.Direct, string joinCode = "", bool ignoreModCompatibilityChecks = false, - bool simulationSync = true) + bool simulationSync = true, string buildId = "", string[] modManifest = null, + bool hostOnlySensitiveTools = false) { Transport = transport; JoinCode = joinCode ?? string.Empty; @@ -95,11 +105,14 @@ public MultiplayerConfig(string playerName, string hostAddress, int port, string UseEncryption = useEncryption; MaxPlayers = maxPlayers < 2 ? 2 : maxPlayers; ModVersion = modVersion ?? string.Empty; + BuildId = buildId ?? string.Empty; IgnoreModCompatibilityChecks = ignoreModCompatibilityChecks; GameVersion = gameVersion ?? string.Empty; DlcList = dlcList ?? System.Array.Empty(); + ModManifest = modManifest ?? System.Array.Empty(); RequireJoinApproval = requireJoinApproval; SimulationSync = simulationSync; + HostOnlySensitiveTools = hostOnlySensitiveTools; } } } diff --git a/CS2MultiplayerMod/Core/Session/MultiplayerSession/Handshake.cs b/CS2MultiplayerMod/Core/Session/MultiplayerSession/Handshake.cs index 5e8d95a..3be4cc9 100644 --- a/CS2MultiplayerMod/Core/Session/MultiplayerSession/Handshake.cs +++ b/CS2MultiplayerMod/Core/Session/MultiplayerSession/Handshake.cs @@ -34,8 +34,8 @@ private void HandleHandshakeChallenge(ConnectionId connection, HandshakeChalleng byte[] binding = _transport.GetChannelBinding(ConnectionId.Server); byte[] proof = HandshakeAuth.ComputeProof(_config.Password, challenge.Nonce, binding); SendTo(connection, new HandshakeRequest( - ProtocolConstants.ProtocolVersion, _config.ModVersion, _config.GameVersion, - LocalPlayerName, proof, _config.DlcList)); + ProtocolConstants.ProtocolVersion, _config.ModVersion, _config.BuildId, _config.GameVersion, + LocalPlayerName, proof, _config.DlcList, _config.ModManifest)); } private void HandleHandshakeRequest(ConnectionId connection, Peer peer, HandshakeRequest request, long nowUnixMs) @@ -53,9 +53,11 @@ private void HandleHandshakeRequest(ConnectionId connection, Peer peer, Handshak _log.Detail(LogTopic.Session, "Handshake request from " + connection + " (" + (peer.RemoteAddress ?? "?") + "): name='" + WireGuard.SanitizePlayerName(request.PlayerName) + "' protocol=" + - request.ProtocolVersion + " mod=" + (request.ModVersion ?? "?") + " game=" + + request.ProtocolVersion + " mod=" + (request.ModVersion ?? "?") + " build=" + + (request.BuildId ?? "?") + " game=" + (request.GameVersion ?? "?") + " dlcs=[" + string.Join(", ", request.DlcList ?? Array.Empty()) + "]" + + " mods=[" + string.Join(", ", request.ModManifest ?? Array.Empty()) + "]" + " passwordProof=" + (request.PasswordProof != null && request.PasswordProof.Length > 0 ? "present" : "missing") + "."); @@ -128,6 +130,13 @@ private void HandleHandshakeRequest(ConnectionId connection, Peer peer, Handshak return; } + string modMismatch = DescribeModMismatch(_config.ModManifest, request.ModManifest); + if (modMismatch != null) + { + Reject(connection, "Mod playset mismatch - " + modMismatch); + return; + } + // Player cap (host counts as one seat). int seated = 1; foreach (var pair in _peers) @@ -143,6 +152,7 @@ private void HandleHandshakeRequest(ConnectionId connection, Peer peer, Handshak // suffixing "(2)" rather than rejecting, keeping the join frictionless. peer.Name = WireGuard.SanitizePlayerName(request.PlayerName); peer.ModVersion = request.ModVersion; + peer.BuildId = request.BuildId; peer.GameVersion = request.GameVersion; // Optional manual gate: hold the join and let the host admit it by hand. The @@ -180,7 +190,8 @@ private void FinalizeJoin(ConnectionId connection, Peer peer, long nowUnixMs) SendTo(connection, HandshakeResponse.Accept(peer.PlayerId, _config.SimulationSync)); _log.Event(LogTopic.Session, "Accepted " + peer + ": mod " + - (string.IsNullOrEmpty(peer.ModVersion) ? "?" : peer.ModVersion) + ", game " + + (string.IsNullOrEmpty(peer.ModVersion) ? "?" : peer.ModVersion) + " build " + + (string.IsNullOrEmpty(peer.BuildId) ? "?" : peer.BuildId) + ", game " + (string.IsNullOrEmpty(peer.GameVersion) ? "?" : peer.GameVersion) + "."); NotifyPeerJoined(peer); @@ -290,6 +301,78 @@ internal static string DescribeDlcMismatch(string[] hostDlcs, string[] clientDlc return sb.ToString(); } + internal static string DescribeModMismatch(string[] hostMods, string[] clientMods) + { + if (hostMods == null) hostMods = Array.Empty(); + if (clientMods == null) clientMods = Array.Empty(); + var host = new HashSet(hostMods, StringComparer.OrdinalIgnoreCase); + var client = new HashSet(clientMods, StringComparer.OrdinalIgnoreCase); + if (host.SetEquals(client)) return null; + + // Compare display names first to report version mismatches clearly. + var hostByName = ManifestByName(hostMods); + var clientByName = ManifestByName(clientMods); + var changed = new List(); + foreach (KeyValuePair item in hostByName) + { + string other; + if (clientByName.TryGetValue(item.Key, out other) && + !string.Equals(item.Value, other, StringComparison.OrdinalIgnoreCase)) + changed.Add(item.Key + " (host " + ManifestBuild(item.Value) + + ", yours " + ManifestBuild(other) + ")"); + } + + var clientMissing = new List(); + foreach (string mod in hostMods) + if (!client.Contains(mod) && !clientByName.ContainsKey(ManifestName(mod))) + clientMissing.Add(mod); + var hostMissing = new List(); + foreach (string mod in clientMods) + if (!host.Contains(mod) && !hostByName.ContainsKey(ManifestName(mod))) + hostMissing.Add(mod); + var detail = new System.Text.StringBuilder(); + if (changed.Count > 0) + detail.Append("different build: ").Append(string.Join(", ", changed.ToArray())); + if (clientMissing.Count > 0) + { + if (detail.Length > 0) detail.Append("; "); + detail.Append("you are missing: ").Append(string.Join(", ", clientMissing.ToArray())); + } + if (hostMissing.Count > 0) + { + if (detail.Length > 0) detail.Append("; "); + detail.Append("the host is missing: ").Append(string.Join(", ", hostMissing.ToArray())); + } + detail.Append(". Both players need the same active mod playset."); + return detail.ToString(); + } + + private static Dictionary ManifestByName(string[] entries) + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + for (int i = 0; i < entries.Length; i++) + { + string entry = entries[i] ?? ""; + string name = ManifestName(entry); + if (!string.IsNullOrEmpty(name)) result[name] = entry; + } + return result; + } + + private static string ManifestName(string entry) + { + if (string.IsNullOrEmpty(entry)) return ""; + int version = entry.LastIndexOf('@'); + return version > 0 ? entry.Substring(0, version) : entry; + } + + private static string ManifestBuild(string entry) + { + int version = entry.LastIndexOf('@'); + return version >= 0 && version + 1 < entry.Length + ? entry.Substring(version + 1) : "unknown"; + } + /// /// Make a joining player's name unique among the host and current peers by /// suffixing " (2)", " (3)", ... when taken. diff --git a/CS2MultiplayerMod/Core/Session/MultiplayerSession/Lifecycle.cs b/CS2MultiplayerMod/Core/Session/MultiplayerSession/Lifecycle.cs index 17e1069..3d47590 100644 --- a/CS2MultiplayerMod/Core/Session/MultiplayerSession/Lifecycle.cs +++ b/CS2MultiplayerMod/Core/Session/MultiplayerSession/Lifecycle.cs @@ -301,6 +301,8 @@ private void Stop(string detail) EncryptionActive = false; _worldSyncSuspended = false; _worldSyncEpoch = 0; + _nowUnixMs = 0; + _postWorldSyncCommandHoldUntilMs = 0; SetStatus(SessionStatus.Offline, string.IsNullOrWhiteSpace(detail) ? "The connection to the host closed." : detail); } diff --git a/CS2MultiplayerMod/Core/Session/MultiplayerSession/Messaging.cs b/CS2MultiplayerMod/Core/Session/MultiplayerSession/Messaging.cs index dcf1faf..b16339b 100644 --- a/CS2MultiplayerMod/Core/Session/MultiplayerSession/Messaging.cs +++ b/CS2MultiplayerMod/Core/Session/MultiplayerSession/Messaging.cs @@ -139,6 +139,22 @@ private static bool IsSyncCommand(string text) => /// Recovery initiated by the mod, independently of a player's sync button. public void RequestAutomaticWorldSync(string reason) => RequestWorldSync(reason, true); + /// Requests recovery for one peer. + public bool RequestWorldSyncForPeer(ConnectionId target, string reason) + { + Peer peer; + if (Role != SessionRole.Host || Status != SessionStatus.Connected || + _worldSyncSuspended || target.IsNone || !_peers.TryGetValue(target.Value, out peer) || + !peer.Handshaked) return false; + reason = WireGuard.SanitizeText(reason, WireGuard.MaxResyncReasonLength); + if (reason.Length == 0) reason = "targeted recovery"; + _log.Event(LogTopic.Session, "Targeted world recovery for " + peer + " (" + reason + ")."); + SendTo(target, new ChatMessage(null, + "The host is refreshing your city after a synchronization recovery.")); + NotifyResyncRequested(peer.PlayerId, target); + return true; + } + private void RequestWorldSync(string reason, bool automatic) { if (Status != SessionStatus.Connected) return; @@ -226,6 +242,13 @@ public void SendCommand(long tick, ushort commandId, byte[] body) { if (Status != SessionStatus.Connected || _worldSyncSuspended) return; + if (Role == SessionRole.Client && _nowUnixMs < _postWorldSyncCommandHoldUntilMs) + { + _log.Detail(LogTopic.Session, "Discarded stale command " + commandId + + " during post-world-sync settle window."); + return; + } + var message = new SimulationCommandMessage(LocalPlayerId, tick, commandId, body); if (Role == SessionRole.Host) { @@ -238,6 +261,38 @@ public void SendCommand(long tick, ushort commandId, byte[] body) } } + /// Replays a command for one peer after a failed realization. + public bool ResendCommandTo(ConnectionId target, SimulationCommandMessage command) + { + Peer peer; + if (Role != SessionRole.Host || Status != SessionStatus.Connected || + _worldSyncSuspended || command == null || target.IsNone || + !_peers.TryGetValue(target.Value, out peer) || !peer.Handshaked) + return false; + byte[] body = command.Body == null ? Array.Empty() : (byte[])command.Body.Clone(); + SendTo(target, new SimulationCommandMessage(command.OriginPlayerId, + command.Tick, command.CommandId, body)); + return true; + } + + /// Report a client-side atomic net-operation result to the host. + public void SendNetOperationReceipt(int originPlayerId, long operationId, bool applied, string detail = null) + { + if (Status != SessionStatus.Connected || Role != SessionRole.Client || operationId <= 0) return; + SendTo(ConnectionId.Server, new NetOperationReceiptMessage(originPlayerId, operationId, applied, detail)); + } + + private void HandleNetOperationReceipt(ConnectionId from, Peer peer, NetOperationReceiptMessage receipt) + { + if (Role != SessionRole.Host || peer == null) return; + if (receipt.OriginPlayerId < 0) { Punt(from, peer, "invalid net receipt origin", "NetOperationReceipt"); return; } + _log.Event(LogTopic.Nets, "Net operation receipt: peer=" + peer.Name + " op=" + + receipt.OperationId + " origin=" + receipt.OriginPlayerId + " result=" + + (receipt.Applied ? "applied" : "failed") + + (string.IsNullOrEmpty(receipt.Detail) ? "" : " detail=" + receipt.Detail)); + NotifyNetOperationReceipt(peer, receipt); + } + private void HandleCommand(ConnectionId from, Peer peer, SimulationCommandMessage command) { // Commands crossing the snapshot cut are deliberately rejected. Every participant @@ -253,6 +308,15 @@ private void HandleCommand(ConnectionId from, Peer peer, SimulationCommandMessag return; } + if (Role == SessionRole.Host && peer != null && _hostOnlyCommandIds.Contains(command.CommandId)) + { + string name = "command " + command.CommandId; + _log.Warn(LogTopic.Session, "Declined " + name + " from " + peer.Name + + ": this host reserves it for the host player."); + SendTo(from, new ChatMessage(null, "The host has reserved this tool for the host player.")); + return; + } + // The origin id drives every echo-skip; stamp it from OUR peer table so a // client cannot impersonate another player (or the host) on the wire. if (Role == SessionRole.Host && peer != null) diff --git a/CS2MultiplayerMod/Core/Session/MultiplayerSession/MultiplayerSession.cs b/CS2MultiplayerMod/Core/Session/MultiplayerSession/MultiplayerSession.cs index 1d29cc5..b06b8ef 100644 --- a/CS2MultiplayerMod/Core/Session/MultiplayerSession/MultiplayerSession.cs +++ b/CS2MultiplayerMod/Core/Session/MultiplayerSession/MultiplayerSession.cs @@ -35,6 +35,9 @@ public sealed partial class MultiplayerSession /// Minimum gap between accepted /sync requests - save+stream is expensive, kept short so post-join syncs aren't silently ignored. private const long ResyncRequestCooldownMs = 5000; + /// Delay capture briefly after a client resumes from recovery. + private const long PostWorldSyncCommandHoldMs = 300; + private readonly IModLogger _log; private readonly MessageCodec _codec; private readonly List _observers = new List(); @@ -44,6 +47,7 @@ public sealed partial class MultiplayerSession private readonly Dictionary _blobTransferIds = new Dictionary(); private readonly Dictionary _allowedBlobChannels = new Dictionary(); private readonly HashSet _allowedCommandIds = new HashSet(); + private readonly HashSet _hostOnlyCommandIds = new HashSet(); private readonly HashSet _administrativeRemovals = new HashSet(); private readonly HashSet _hostBannedAddresses = new HashSet(); // Connections already told to go. The transport only removes a peer when its @@ -65,6 +69,8 @@ public sealed partial class MultiplayerSession private bool _awaitingHostApproval; private bool _worldSyncSuspended; private long _worldSyncEpoch; + private long _nowUnixMs; + private long _postWorldSyncCommandHoldUntilMs; public MultiplayerSession(IModLogger log, MessageCodec codec = null) { @@ -192,6 +198,14 @@ public void AllowCommands(params ushort[] commandIds) for (int i = 0; i < commandIds.Length; i++) _allowedCommandIds.Add(commandIds[i]); } + /// Checks whether a peer may use a host-restricted tool. + public void SetHostOnlyCommands(params ushort[] commandIds) + { + _hostOnlyCommandIds.Clear(); + if (commandIds == null) return; + for (int i = 0; i < commandIds.Length; i++) _hostOnlyCommandIds.Add(commandIds[i]); + } + // ---- Lifecycle -------------------------------------------------------- @@ -207,6 +221,7 @@ public void AllowCommands(params ushort[] commandIds) /// public void Update(long nowUnixMs) { + _nowUnixMs = nowUnixMs; if (_transport == null) return; _eventBuffer.Clear(); diff --git a/CS2MultiplayerMod/Core/Session/MultiplayerSession/Notify.cs b/CS2MultiplayerMod/Core/Session/MultiplayerSession/Notify.cs index 5b51f71..88e23a8 100644 --- a/CS2MultiplayerMod/Core/Session/MultiplayerSession/Notify.cs +++ b/CS2MultiplayerMod/Core/Session/MultiplayerSession/Notify.cs @@ -73,6 +73,12 @@ private void NotifyCommand(SimulationCommandMessage command) try { _observers[i].OnCommandReceived(command); } catch (Exception ex) { LogObserverError("OnCommandReceived", ex); } } + private void NotifyNetOperationReceipt(Peer peer, NetOperationReceiptMessage receipt) + { + for (int i = 0; i < _observers.Count; i++) + try { _observers[i].OnNetOperationReceipt(peer, receipt); } + catch (Exception ex) { LogObserverError("OnNetOperationReceipt", ex); } + } private void NotifyState(StateSnapshotMessage snapshot) { diff --git a/CS2MultiplayerMod/Core/Session/MultiplayerSession/Transport.cs b/CS2MultiplayerMod/Core/Session/MultiplayerSession/Transport.cs index 84a54ca..af0f7ce 100644 --- a/CS2MultiplayerMod/Core/Session/MultiplayerSession/Transport.cs +++ b/CS2MultiplayerMod/Core/Session/MultiplayerSession/Transport.cs @@ -248,6 +248,9 @@ private void Dispatch(ConnectionId connection, Peer peer, INetMessage message, i ((ResyncRequestMessage)message).Reason, ((ResyncRequestMessage)message).IsAutomatic); break; + case MessageType.NetOperationReceipt: + HandleNetOperationReceipt(connection, peer, (NetOperationReceiptMessage)message); + break; case MessageType.WorldSyncControl: HandleWorldSyncControl(connection, peer, (WorldSyncControlMessage)message); break; diff --git a/CS2MultiplayerMod/Core/Session/MultiplayerSession/WorldSync.cs b/CS2MultiplayerMod/Core/Session/MultiplayerSession/WorldSync.cs index 0a13b44..cbff0bd 100644 --- a/CS2MultiplayerMod/Core/Session/MultiplayerSession/WorldSync.cs +++ b/CS2MultiplayerMod/Core/Session/MultiplayerSession/WorldSync.cs @@ -172,6 +172,12 @@ private void HandleWorldSyncControl(ConnectionId from, Peer peer, NotifyWorldSync(control.Stage, control.Epoch, control.ResumeSpeed, from); _worldSyncSuspended = false; _worldSyncEpoch = 0; + if (control.Stage == WorldSyncStage.Resume) + { + _postWorldSyncCommandHoldUntilMs = _nowUnixMs + PostWorldSyncCommandHoldMs; + _log.Detail(LogTopic.WorldTransfer, "Holding client commands for " + + PostWorldSyncCommandHoldMs + " ms after world sync resume."); + } if (control.Stage == WorldSyncStage.Abort) { _blobs.Clear(); diff --git a/CS2MultiplayerMod/Core/Session/Peers/Peer.cs b/CS2MultiplayerMod/Core/Session/Peers/Peer.cs index 8d77829..9e3c3c5 100644 --- a/CS2MultiplayerMod/Core/Session/Peers/Peer.cs +++ b/CS2MultiplayerMod/Core/Session/Peers/Peer.cs @@ -44,6 +44,9 @@ public sealed class Peer /// public string ModVersion; + /// Exact source artifact reported by the peer during its handshake. + public string BuildId; + /// The peer's game version, for the same reason as . public string GameVersion; diff --git a/CS2MultiplayerMod/Core/Session/Peers/PeerRateLimiter.cs b/CS2MultiplayerMod/Core/Session/Peers/PeerRateLimiter.cs index 97476ff..7c2a6df 100644 --- a/CS2MultiplayerMod/Core/Session/Peers/PeerRateLimiter.cs +++ b/CS2MultiplayerMod/Core/Session/Peers/PeerRateLimiter.cs @@ -17,7 +17,8 @@ public sealed class PeerRateLimiter // real backstop against bandwidth/packet floods. public const int MaxMessagesPerSecond = 3000; public const int MaxBytesPerSecond = 4 * 1024 * 1024; - public const int MaxCommandsPerSecond = 1500; + // Allow the initial command burst after a client recovers. + public const int MaxCommandsPerSecond = 3000; public const int MaxChatPerSecond = 5; public const int MaxResyncPerMinute = 2; @@ -30,6 +31,11 @@ public sealed class PeerRateLimiter private long _minuteStartMs; private int _resyncs; + /// Read-only limiter state for diagnostics. + public string Snapshot => "messages=" + _messages + " bytes=" + _bytes + + " commands=" + _commands + " chat=" + _chat + + " resyncs=" + _resyncs; + /// Account one received message. Returns null if fine, else the violated budget's name. public string Account(long nowMs, int payloadBytes, bool isCommand, bool isChat, bool isResync) { diff --git a/CS2MultiplayerMod/Game/Diagnostics/FlightRecorder.cs b/CS2MultiplayerMod/Game/Diagnostics/FlightRecorder.cs index a318152..6c4bb2c 100644 --- a/CS2MultiplayerMod/Game/Diagnostics/FlightRecorder.cs +++ b/CS2MultiplayerMod/Game/Diagnostics/FlightRecorder.cs @@ -147,6 +147,49 @@ public static void Stop() } } + /// Writes the session snapshot and flight log to one text file. + public static string ExportDiagnosticBundle(string reason, string sessionSnapshot) + { + if (!Enabled) return null; + try + { + Note("diagnostic-export reason=" + Quote(reason) + + " session=" + Quote(sessionSnapshot)); + + string dir = LogsDirectory(); + if (string.IsNullOrEmpty(dir)) return null; + Directory.CreateDirectory(dir); + + string stamp = DateTime.UtcNow.ToString("yyyyMMdd-HHmmss-fff", CultureInfo.InvariantCulture) + + "-" + Guid.NewGuid().ToString("N").Substring(0, 6); + string destination = Path.Combine(dir, "CS2MP-diagnostic-" + stamp + ".txt"); + string flightLog = Path.Combine(dir, "CS2MP-flight.log"); + using (var output = new StreamWriter(destination, false, new UTF8Encoding(false))) + { + output.WriteLine("CS2 Multiplayer diagnostic bundle"); + output.WriteLine("createdUtc=" + DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture)); + output.WriteLine("reason=" + Compact(reason, MaxExceptionChars)); + output.WriteLine("session=" + Compact(sessionSnapshot, MaxExceptionChars)); + output.WriteLine("flightLog=CS2MP-flight.log (embedded below)"); + output.WriteLine(); + output.WriteLine("--- flight log ---"); + if (File.Exists(flightLog)) + { + using (var input = new FileStream(flightLog, FileMode.Open, FileAccess.Read, + FileShare.ReadWrite | FileShare.Delete)) + using (var reader = new StreamReader(input, Encoding.UTF8, true)) + output.Write(reader.ReadToEnd()); + } + else output.WriteLine("flight log unavailable"); + } + return destination; + } + catch + { + return null; + } + } + /// /// Append one structured line and flush it. Safe from any thread and never throws. /// diff --git a/CS2MultiplayerMod/Game/MultiplayerService/Checks/ModCompatibilityCatalog.cs b/CS2MultiplayerMod/Game/MultiplayerService/Checks/ModCompatibilityCatalog.cs new file mode 100644 index 0000000..cd0f606 --- /dev/null +++ b/CS2MultiplayerMod/Game/MultiplayerService/Checks/ModCompatibilityCatalog.cs @@ -0,0 +1,122 @@ +using System; +using System.Collections.Generic; + +namespace CS2MultiplayerMod.Game +{ + /// Compatibility policy for other active mods. + internal static class ModCompatibilityCatalog + { + internal enum Support + { + Allowed, + Restricted, + Blocked, + Unknown + } + + internal enum Risk + { + Cosmetic, + PersistentWorld, + NetworkOrTerrain, + Simulation, + Unknown + } + + private static readonly Dictionary Entries = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + // Supported mods. + { "Traffic", Support.Allowed }, + { "Road Speed Adjuster", Support.Allowed }, + { "Anarchy", Support.Allowed }, + { "Building Use", Support.Allowed }, + { "Custom Chirps", Support.Allowed }, + { "Extended Tooltip", Support.Allowed }, + { "I18n Everywhere", Support.Allowed }, + { "Find It", Support.Allowed }, + { "Region Flag Icons", Support.Allowed }, + { "Asset Icon Library", Support.Allowed }, + { "Unified Icon Library", Support.Allowed }, + { "Extra Lib", Support.Allowed }, + { "Industry Boundary", Support.Allowed }, + { "All Transit + Trucks", Support.Allowed }, + { "Lumina", Support.Allowed }, + { "Stop Jaywalking", Support.Allowed }, + { "Road Name Remover", Support.Allowed }, + { "Achievement Fixer", Support.Allowed }, + // Runtime assembly name used by Achievement Fixer. + { "AchievementFixer", Support.Allowed }, + { "Specialized Industry Freedom", Support.Allowed }, + { "Articulated Buses", Support.Allowed }, + { "No Vehicle Despawn", Support.Allowed }, + { "Realistic JobSearch", Support.Allowed }, + { "Realistic Trips", Support.Allowed }, + { "Realistic Workplaces And Households", Support.Allowed }, + { "Traffic Tool Essentials", Support.Allowed }, + { "Official Region Packs", Support.Allowed }, + + // Supported with limitations; record them in the session log. + { "Move It", Support.Restricted }, + { "Node Controller", Support.Restricted }, + { "Traffic Lights Enhancement", Support.Restricted }, + { "CoPaste", Support.Restricted }, + { "529 Tiles", Support.Restricted }, + { "Change Company", Support.Restricted }, + { "Event Rush", Support.Restricted }, + { "Decals / Props", Support.Restricted }, + + // Known crash or desync risk. + { "Better Bulldozer", Support.Blocked } + }; + + public static Support Classify(string name) + { + if (string.IsNullOrWhiteSpace(name)) return Support.Unknown; + return Entries.TryGetValue(name.Trim(), out Support support) ? support : Support.Unknown; + } + + public static bool BlocksStart(string name) + { + Support support = Classify(name); + return support == Support.Blocked || support == Support.Unknown; + } + + public static string Label(string name) + { + switch (Classify(name)) + { + case Support.Allowed: return "allowed"; + case Support.Restricted: return "restricted"; + case Support.Blocked: return "blocked"; + default: return "unreviewed"; + } + } + + /// Risk class used for compatibility checks. + public static Risk RiskOf(string name) + { + switch (Classify(name)) + { + case Support.Unknown: return Risk.Unknown; + case Support.Blocked: return Risk.NetworkOrTerrain; + } + if (string.Equals(name, "Lumina", StringComparison.OrdinalIgnoreCase) || + string.Equals(name, "Extended Tooltip", StringComparison.OrdinalIgnoreCase) || + string.Equals(name, "Road Name Remover", StringComparison.OrdinalIgnoreCase) || + string.Equals(name, "Achievement Fixer", StringComparison.OrdinalIgnoreCase) || + string.Equals(name, "AchievementFixer", StringComparison.OrdinalIgnoreCase)) + return Risk.Cosmetic; + if (string.Equals(name, "Traffic", StringComparison.OrdinalIgnoreCase) || + string.Equals(name, "Traffic Lights Enhancement", StringComparison.OrdinalIgnoreCase) || + string.Equals(name, "Realistic Trips", StringComparison.OrdinalIgnoreCase)) + return Risk.Simulation; + if (string.Equals(name, "Move It", StringComparison.OrdinalIgnoreCase) || + string.Equals(name, "Node Controller", StringComparison.OrdinalIgnoreCase) || + string.Equals(name, "CoPaste", StringComparison.OrdinalIgnoreCase) || + string.Equals(name, "Decals / Props", StringComparison.OrdinalIgnoreCase)) + return Risk.NetworkOrTerrain; + return Risk.PersistentWorld; + } + } +} diff --git a/CS2MultiplayerMod/Game/MultiplayerService/Checks/ModsCheck.cs b/CS2MultiplayerMod/Game/MultiplayerService/Checks/ModsCheck.cs index 9b8cf48..fb34eca 100644 --- a/CS2MultiplayerMod/Game/MultiplayerService/Checks/ModsCheck.cs +++ b/CS2MultiplayerMod/Game/MultiplayerService/Checks/ModsCheck.cs @@ -3,6 +3,7 @@ using System.Diagnostics; using System.IO; using System.Reflection; +using System.Security.Cryptography; using Colossal.IO.AssetDatabase; using Colossal.PSI.Common; using CS2MultiplayerMod.Core.Diagnostics; @@ -16,10 +17,7 @@ namespace CS2MultiplayerMod.Game { /// - /// Finds every mod other than this one that is live for the running game. Hosting and - /// joining are both refused while any is present: nothing in the sync layer accounts - /// for a third party changing prefabs, tools or the simulation, so one such mod on one - /// side is enough to desync the session or crash the other player. + /// Checks the active playset against . /// /// The active Paradox Mods playset is the source of truth wherever it can be read: it /// tracks what the player toggles live, and it is the only source that also lists @@ -95,13 +93,73 @@ public static string[] OtherModNames public static bool AnyOtherMods => OtherModNames.Length > 0; + /// Builds the active-mod manifest for the handshake. + public static string[] Manifest + { + get + { + string[] names = OtherModNames; + var manifest = new string[names.Length]; + for (int i = 0; i < names.Length; i++) + manifest[i] = names[i] + "@" + LoadedVersion(names[i]) + "#" + LoadedHash(names[i]); + return manifest; + } + } + + private static string LoadedVersion(string name) + { + try + { + ModManager manager = GameManager.instance != null ? GameManager.instance.modManager : null; + if (manager != null) foreach (ModManager.ModInfo info in manager) + { + if (info == null || info.asset == null || !info.asset.isMod || !info.isLoaded) continue; + if (!string.Equals(LoadedName(info), name, StringComparison.OrdinalIgnoreCase)) continue; + Version version = info.asset.version; + return version == null ? "unknown" : version.ToString(); + } + } + catch (Exception ex) { WarnOnce("loaded mod versions", ex); } + return "unknown"; + } + + private static string LoadedHash(string name) + { + try + { + ModManager manager = GameManager.instance != null ? GameManager.instance.modManager : null; + if (manager != null) foreach (ModManager.ModInfo info in manager) + { + if (info == null || info.asset == null || !info.asset.isMod || !info.isLoaded || + !string.Equals(LoadedName(info), name, StringComparison.OrdinalIgnoreCase)) continue; + string path = info.asset.path; + if (string.IsNullOrEmpty(path) || !File.Exists(path)) return "unknown"; + using (var sha = SHA256.Create()) + using (var stream = File.OpenRead(path)) + return BitConverter.ToString(sha.ComputeHash(stream)).Replace("-", "").ToLowerInvariant(); + } + } + catch (Exception ex) { WarnOnce("loaded mod hashes", ex); } + return "unknown"; + } + + public static bool AnyBlockingMods + { + get + { + foreach (string name in OtherModNames) + if (ModCompatibilityCatalog.BlocksStart(name)) return true; + return false; + } + } + /// /// Localized sentence naming the offending mods for the blocking banner, or "" when /// nothing else is running (which hides the banner). /// public static string BlockText(bool ignored = false) { - string[] names = OtherModNames; + string[] names = BlockingModNames(); if (names.Length == 0) return ""; // Reading the names is what refreshes _restartRequired, so the order matters. @@ -118,7 +176,7 @@ public static string BlockText(bool ignored = false) /// public static string FaultDetail() { - string[] names = OtherModNames; + string[] names = BlockingModNames(); return names.Length == 0 ? "" : FaultMarker + " " + NamesText(names); } @@ -130,7 +188,22 @@ public static string FaultDetail() public static string Summary() { string[] names = OtherModNames; - return names.Length == 0 ? "none" : "[" + NamesText(names) + "]"; + if (names.Length == 0) return "none"; + + var labelled = new string[names.Length]; + for (int i = 0; i < names.Length; i++) + labelled[i] = names[i] + "=" + ModCompatibilityCatalog.Label(names[i]) + + "/" + ModCompatibilityCatalog.RiskOf(names[i]); + return "[" + NamesText(labelled) + "]"; + } + + private static string[] BlockingModNames() + { + string[] names = OtherModNames; + var blocking = new List(); + foreach (string name in names) + if (ModCompatibilityCatalog.BlocksStart(name)) blocking.Add(name); + return blocking.ToArray(); } /// Comma-separated names, truncated to . @@ -368,8 +441,7 @@ private static void LogChange(string[] previous, string[] current) SyncLog.Event(LogTopic.Startup, current.Length == 0 ? "No other mods are active - multiplayer is available." - : "Other mods are active, from the " + source + ": " + - string.Join(", ", current) + "."); + : "Other mods are active, from the " + source + ": " + Summary() + "."); } private static void WarnOnce(string source, Exception ex) diff --git a/CS2MultiplayerMod/Game/MultiplayerService/GameplayCommandRegistry.cs b/CS2MultiplayerMod/Game/MultiplayerService/GameplayCommandRegistry.cs index 47be437..575ea0d 100644 --- a/CS2MultiplayerMod/Game/MultiplayerService/GameplayCommandRegistry.cs +++ b/CS2MultiplayerMod/Game/MultiplayerService/GameplayCommandRegistry.cs @@ -33,6 +33,19 @@ internal static void Register(MultiplayerSession session) session.AllowCommands(AllowedCommandIds); } + internal static void ApplyHostRolePolicy(MultiplayerSession session, bool hostOnlySensitiveTools) + { + if (!hostOnlySensitiveTools) + { + session.SetHostOnlyCommands(); + return; + } + + session.SetHostOnlyCommands( + TerrainBrushCommand.Id, EntityPolicyCommand.Id, DevTreePurchaseCommand.Id, + TilePurchaseCommand.Id, DisasterEventCommand.Id); + } + /// A copy for callers that iterate the allow-list without being able to edit it. internal static ushort[] CopyAllowedIds() => (ushort[])AllowedCommandIds.Clone(); diff --git a/CS2MultiplayerMod/Game/MultiplayerService/Lifecycle/Phase.cs b/CS2MultiplayerMod/Game/MultiplayerService/Lifecycle/Phase.cs index c322703..16c9909 100644 --- a/CS2MultiplayerMod/Game/MultiplayerService/Lifecycle/Phase.cs +++ b/CS2MultiplayerMod/Game/MultiplayerService/Lifecycle/Phase.cs @@ -152,7 +152,7 @@ private void RestoreAutosave() } /// - /// Refuses the action when any mod other than this one is live, and records the + /// Refuses the action when an unreviewed or blocked mod is live, and records the /// reason as a fault so the status screen and the error overlay explain it. Enforced /// here rather than only in the UI because the options screen's Host button and the /// hub reach these entry points directly. True when the caller must stop. @@ -172,8 +172,8 @@ private bool RefuseForOtherMods(string action) _lastFault = detail; _log.Warn(LogTopic.Session, "Cannot " + action + ": " + detail + - ". Multiplayer runs only with CS2 Multiplayer Mod alone - disable the " + - "others in the active playset and restart the game."); + ". Disable the unreviewed or blocked mods in the active playset and restart " + + "the game, or use the explicit own-risk override."); return true; } @@ -185,7 +185,8 @@ private static string ModVersionText(MultiplayerConfig config) { return " mod=" + Mod.Version + (string.Equals(Mod.Version, config.ModVersion, StringComparison.Ordinal) - ? "" : " compat=" + config.ModVersion); + ? "" : " compat=" + config.ModVersion) + + " build=" + (string.IsNullOrEmpty(config.BuildId) ? "unknown" : config.BuildId); } /// @@ -205,11 +206,20 @@ public void HostFromSettings(Setting settings) if (!ModEnabled) { _log.Warn(LogTopic.Session, "Cannot host: the mod is disabled in settings."); return; } if (_session.Role != SessionRole.None) { _log.Warn(LogTopic.Session, "Cannot host: a session is already active."); return; } if (RefuseForOtherMods("host")) return; + // Direct internet hosts require a password. + if (settings != null && settings.HostTransport() == TransportMode.Direct && !settings.LanOnly && + (settings.HostPassword ?? "").Trim().Length < 12) + { + _lastFault = "Public direct hosting requires a password of at least 12 characters."; + _log.Warn(LogTopic.Session, "Cannot host publicly without a strong password. Use Steam Relay, enable LAN-only, or set a password of at least 12 characters."); + return; + } _disconnectConfirmationRequested = false; ClearClientExitNotice(); ResetCommandDiagnostics(); _lastFault = null; var config = BuildConfig(settings, hosting: true); + GameplayCommandRegistry.ApplyHostRolePolicy(_session, config.HostOnlySensitiveTools); _log.Event(LogTopic.Session, "Host requested: transport=" + config.Transport + (config.Transport == TransportMode.SteamRelay ? " joinCode=" + RelayProvider.LocalJoinCode : " port=" + config.Port) + " lanOnly=" + config.LanOnly + " password=" + @@ -373,7 +383,10 @@ private MultiplayerConfig BuildConfig(Setting settings, bool hosting) transport: transport, joinCode: relay && !hosting ? joinCode : "", ignoreModCompatibilityChecks: settings.IgnoreModCompatibilityChecks, - simulationSync: settings.SimulationSync); + simulationSync: settings.SimulationSync, + buildId: Mod.BuildId, + modManifest: ModsCheck.Manifest, + hostOnlySensitiveTools: settings.HostOnlySensitiveTools); } } diff --git a/CS2MultiplayerMod/Game/MultiplayerService/MultiplayerService.cs b/CS2MultiplayerMod/Game/MultiplayerService/MultiplayerService.cs index 1525e71..64bf54b 100644 --- a/CS2MultiplayerMod/Game/MultiplayerService/MultiplayerService.cs +++ b/CS2MultiplayerMod/Game/MultiplayerService/MultiplayerService.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Diagnostics; using CS2MultiplayerMod.Core.Diagnostics; +using CS2MultiplayerMod.Core.Protocol; using CS2MultiplayerMod.Core.Protocol.Messages; using CS2MultiplayerMod.Core.Session; using CS2MultiplayerMod.Game.Diagnostics; @@ -198,6 +199,37 @@ private static string CommandName(ushort id) /// /sync: ask the host for a fresh world stream (host: refresh everyone). public void RequestWorldSync() => _session.RequestWorldSync(); + /// Write a shareable local diagnostic attachment and return its filename. + public string ExportDiagnostics(string reason) + { + string snapshot = "role=" + _session.Role + + " status=" + _session.Status + + " phase=" + _phase + + " build=" + Mod.BuildId + + " protocol=" + ProtocolConstants.ProtocolVersion + + " mods=" + ModsCheck.Summary() + + " peers=" + PeerDiagnostics() + + " process=" + FlightRecorder.ProcessSnapshot(); + string path = FlightRecorder.ExportDiagnosticBundle(reason, snapshot); + if (path != null) + _log.Event(LogTopic.Session, "Diagnostic bundle written: " + path); + else + _log.Warn(LogTopic.Session, "Could not write diagnostic bundle."); + return path; + } + + private string PeerDiagnostics() + { + var parts = new List(); + foreach (Peer peer in _session.Peers) + { + if (!peer.Handshaked) continue; + parts.Add("#" + peer.PlayerId + ":" + peer.Name + " latencyMs=" + peer.LatencyMs + + " " + peer.RateLimiter.Snapshot); + } + return parts.Count == 0 ? "none" : string.Join(";", parts.ToArray()); + } + /// /// One unresolved remote edit (a missed native capture, an owned sub-element that would not /// resolve) must never loop the whole tens-of-MB world through recovery. A single automatic @@ -334,6 +366,7 @@ private void RunAutomaticWorldRecovery(Diagnostics.ResyncReport report) return; } _lastAutoRecoveryMs = now; + ExportDiagnostics("automatic-world-recovery: " + report.Summary()); Diagnostics.SyncLog.Event(LogTopic.Session, "World sync: reloading this city from the host now (" + report.Summary() + ")."); // Include the subject in the existing bounded reason field: host-only logs must @@ -351,6 +384,10 @@ private void RunAutomaticWorldRecovery(Diagnostics.ResyncReport report) private int _nextChatId = 1; private string _chatLogJson = "[]"; private string _playerListJson = "[]"; + private readonly Dictionary _netOperationStatuses = + new Dictionary(); + private readonly Queue _netOperationStatusOrder = new Queue(); + private const int MaxTrackedNetOperations = 32; /// /// The chat/event feed as a JSON array for the hub panel binding: @@ -383,6 +420,103 @@ public void BanPlayerFromUi(int playerId) playerId + "."); } + private void RecordNetOperationBroadcast(long operationId, SimulationCommandMessage command) + { + if (_session.Role != SessionRole.Host || operationId <= 0) return; + lock (_chatLock) + { + var status = new NetOperationPeerStatus { Command = new SimulationCommandMessage( + command.OriginPlayerId, command.Tick, command.CommandId, + command.Body == null ? null : (byte[])command.Body.Clone()) }; + foreach (Peer peer in _session.Peers) + if (peer.Handshaked) + status.ByPlayer[peer.PlayerId] = peer.PlayerId == command.OriginPlayerId + ? "applied (source)" : "waiting"; + _netOperationStatuses[operationId] = status; + _netOperationStatusOrder.Enqueue(operationId); + while (_netOperationStatusOrder.Count > MaxTrackedNetOperations) + _netOperationStatuses.Remove(_netOperationStatusOrder.Dequeue()); + } + RefreshPlayerListJson(); + } + + private void ClearNetOperationStatuses() + { + lock (_chatLock) + { + _netOperationStatuses.Clear(); + _netOperationStatusOrder.Clear(); + } + } + + private void RecordNetOperationReceipt(Peer peer, NetOperationReceiptMessage receipt) + { + if (peer == null || receipt == null) return; + lock (_chatLock) + { + NetOperationPeerStatus status; + if (!_netOperationStatuses.TryGetValue(receipt.OperationId, out status)) return; + status.ByPlayer[peer.PlayerId] = receipt.Applied ? "applied" : "failed"; + } + RefreshPlayerListJson(); + } + + private bool RetryFailedNetOperation(Peer peer, NetOperationReceiptMessage receipt) + { + if (peer == null || receipt == null || receipt.Applied) return false; + SimulationCommandMessage command = null; + bool recover = false; + lock (_chatLock) + { + NetOperationPeerStatus status; + if (!_netOperationStatuses.TryGetValue(receipt.OperationId, out status) || + status.Command == null) return false; + int retries; + status.Retries.TryGetValue(peer.PlayerId, out retries); + if (retries >= 1) + { + status.ByPlayer[peer.PlayerId] = "recovering"; + recover = true; + } + else + { + status.Retries[peer.PlayerId] = retries + 1; + status.ByPlayer[peer.PlayerId] = "retrying"; + command = status.Command; + } + } + if (recover) + { + bool started = _session.RequestWorldSyncForPeer(peer.Connection, + "net operation #" + receipt.OperationId + " failed after retry"); + if (!started) return false; + RefreshPlayerListJson(); + return true; + } + if (_session.ResendCommandTo(peer.Connection, command)) + { + RefreshPlayerListJson(); + return true; + } + return false; + } + + private string LatestNetOperationStatus(int playerId) + { + long[] keys = _netOperationStatusOrder.ToArray(); + for (int i = keys.Length - 1; i >= 0; i--) + { + NetOperationPeerStatus status; + if (_netOperationStatuses.TryGetValue(keys[i], out status)) + { + string value; + if (status.ByPlayer.TryGetValue(playerId, out value)) + return "op #" + keys[i] + ": " + value; + } + } + return ""; + } + private void RefreshPlayerListJson() { lock (_chatLock) @@ -413,7 +547,12 @@ private void RefreshPlayerListJson() Peer peer = peers[i]; sb.Append(",{\"id\":").Append(peer.PlayerId).Append(",\"name\":"); AppendJsonString(sb, peer.Name); - sb.Append(",\"isHost\":false}"); + sb.Append(",\"isHost\":false,\"latencyMs\":").Append(peer.LatencyMs) + .Append(",\"traffic\":"); + AppendJsonString(sb, peer.RateLimiter.Snapshot); + sb.Append(",\"netStatus\":"); + AppendJsonString(sb, LatestNetOperationStatus(peer.PlayerId)); + sb.Append('}'); } sb.Append(']'); } @@ -433,6 +572,13 @@ private struct ChatLogEntry public string Time; } + private sealed class NetOperationPeerStatus + { + public readonly Dictionary ByPlayer = new Dictionary(); + public readonly Dictionary Retries = new Dictionary(); + public SimulationCommandMessage Command; + } + @@ -468,6 +614,8 @@ public override void OnStatusChanged(SessionStatus status, string detail) // Authenticated; the host streams its world to every fresh join. _service.SetPhase(ClientWorldPhase.WaitingForMap); } + if (status == SessionStatus.Connected && _service._session.Role == SessionRole.Host) + _service.ClearNetOperationStatuses(); else if (status == SessionStatus.Offline || status == SessionStatus.Faulted) { // Core teardown deliberately knows nothing about game worlds. If this @@ -481,7 +629,11 @@ public override void OnStatusChanged(SessionStatus status, string detail) _service.QueueClientMainMenu(reason); } - if (status == SessionStatus.Faulted) _service._lastFault = detail; + if (status == SessionStatus.Faulted) + { + _service._lastFault = detail; + _service.ExportDiagnostics("session-fault: " + detail); + } _service.ResetWorldSyncState(restoreSpeed: true); _service.SetPhase(ClientWorldPhase.None); _service._remotePlayers.Clear(); @@ -550,6 +702,30 @@ public override void OnChatReceived(string sender, string text) public override void OnCommandReceived(SimulationCommandMessage command) { _service.RecordAppliedCommand(command); + if (_service._session.Role == SessionRole.Host && + command.CommandId == Sync.Commands.NetToolOperationCommand.Id) + { + try + { + Sync.Commands.NetToolOperationCommand operation = + Sync.Commands.NetToolOperationCommand.Decode(command.Body); + _service.RecordNetOperationBroadcast(operation.OperationId, command); + _service.AppendChatEntry(null, "Net operation #" + operation.OperationId + + " sent; waiting for each client to apply it."); + } + catch { } + } + } + public override void OnNetOperationReceipt(Peer peer, NetOperationReceiptMessage receipt) + { + _service.RecordNetOperationReceipt(peer, receipt); + string name = peer != null && !string.IsNullOrEmpty(peer.Name) ? peer.Name : "client"; + _service.AppendChatEntry(null, "Net operation #" + receipt.OperationId + " " + + (receipt.Applied ? "applied by " : "failed on ") + name + + (string.IsNullOrEmpty(receipt.Detail) ? "." : ": " + receipt.Detail)); + if (_service.RetryFailedNetOperation(peer, receipt)) + _service.AppendChatEntry(null, "Net operation #" + receipt.OperationId + + " started a targeted recovery action for " + name + "."); } public override void OnPlayerStateReceived(PlayerStateMessage state) => _service.RecordRemotePlayer(state); public override void OnBlobReceived(string channel, long transferId, byte[] data) diff --git a/CS2MultiplayerMod/Game/MultiplayerService/Ui/Chat.cs b/CS2MultiplayerMod/Game/MultiplayerService/Ui/Chat.cs index 5bc1f28..e5828ad 100644 --- a/CS2MultiplayerMod/Game/MultiplayerService/Ui/Chat.cs +++ b/CS2MultiplayerMod/Game/MultiplayerService/Ui/Chat.cs @@ -11,6 +11,7 @@ public sealed partial class MultiplayerService /// (the host only relays, a client only uploads), so the local copy is added /// here - sanitized exactly like the wire copy the other players will see. /// "/sync" stays a command and gets its feedback from the host's broadcast notice. + /// "/diag" is local and writes a ready-to-attach diagnostic bundle. /// public void SendChatFromUi(string text) { @@ -18,6 +19,15 @@ public void SendChatFromUi(string text) text = text.Trim(); if (text.Length == 0) return; + if (text.Equals("/diag", StringComparison.OrdinalIgnoreCase)) + { + string path = ExportDiagnostics("manual /diag request"); + AppendChatEntry(null, path == null + ? "Could not write diagnostic bundle; see the main log." + : "Diagnostic bundle saved: " + System.IO.Path.GetFileName(path)); + return; + } + if (!text.Equals("/sync", StringComparison.OrdinalIgnoreCase)) { string echo = WireGuard.SanitizeText(text, WireGuard.MaxChatLength); diff --git a/CS2MultiplayerMod/Game/MultiplayerService/Ui/Status.cs b/CS2MultiplayerMod/Game/MultiplayerService/Ui/Status.cs index 7dc6bae..38baec1 100644 --- a/CS2MultiplayerMod/Game/MultiplayerService/Ui/Status.cs +++ b/CS2MultiplayerMod/Game/MultiplayerService/Ui/Status.cs @@ -377,6 +377,8 @@ private static string FriendlyFaultSummary(string fault) return L10n.T(L10n.Key.ErrorGameVersion); if (FaultContains(fault, "DLC mismatch")) return L10n.T(L10n.Key.ErrorDlc); + if (FaultContains(fault, "Mod playset mismatch")) + return L10n.T(L10n.Key.ErrorMods); if (FaultContains(fault, ModsCheck.FaultMarker)) return L10n.T(L10n.Key.ErrorMods); if (FaultContains(fault, "Server is full")) @@ -419,6 +421,8 @@ private static string FriendlyFaultHelp(string fault) ? detail + " " + L10n.T(L10n.Key.ErrorDlcHelp) : L10n.T(L10n.Key.ErrorDlcHelp); } + if (FaultContains(fault, "Mod playset mismatch")) + return fault + " Enable the same active mods as the host, then join again."; if (FaultContains(fault, ModsCheck.FaultMarker)) { string detail = MarkedDetail(fault, ModsCheck.FaultMarker); diff --git a/CS2MultiplayerMod/Game/MultiplayerService/WorldTransfer/WorldTransfer.cs b/CS2MultiplayerMod/Game/MultiplayerService/WorldTransfer/WorldTransfer.cs index 3b6387f..e4b3469 100644 --- a/CS2MultiplayerMod/Game/MultiplayerService/WorldTransfer/WorldTransfer.cs +++ b/CS2MultiplayerMod/Game/MultiplayerService/WorldTransfer/WorldTransfer.cs @@ -102,6 +102,7 @@ private async Task SaveWorldSnapshot(World world, long epoch, Cancel if (!snapshotDatabase.Exists(packagePath, out package) || package == null) throw new InvalidOperationException("The game did not create the world snapshot package."); + PersistRecoveryBackup(package, epoch); BlobSource data = ReadWorldSnapshotPackage(package, cancellation); _log.Detail(LogTopic.WorldTransfer, "Prepared isolated recovery snapshot '" + WorldSnapshotFileName + "' (" + (data.Length / 1024) + " KB)."); @@ -132,6 +133,28 @@ private async Task SaveWorldSnapshot(World world, long epoch, Cancel } } + /// Keeps a local copy of a recovery snapshot before sending it. + private void PersistRecoveryBackup(PackageAsset package, long epoch) + { + try + { + string root = Colossal.PSI.Environment.EnvPath.kUserDataPath; + if (string.IsNullOrEmpty(root)) throw new InvalidOperationException("user-data path unavailable"); + string dir = Path.Combine(root, "CS2MP-backups"); + Directory.CreateDirectory(dir); + string path = Path.Combine(dir, "host-recovery-" + epoch + "-" + + DateTime.UtcNow.ToString("yyyyMMdd-HHmmss") + ".cok"); + using (Stream input = package.GetReadStream()) + using (var output = new FileStream(path, FileMode.CreateNew, FileAccess.Write, FileShare.Read)) + input.CopyTo(output); + _log.Event(LogTopic.WorldTransfer, "Recovery backup created: " + path); + } + catch (Exception ex) + { + _log.Warn(LogTopic.WorldTransfer, "Recovery backup could not be created: " + ex.Message); + } + } + private static BlobSource ReadWorldSnapshotPackage(PackageAsset package, CancellationToken cancellation) { using (Stream input = package.GetReadStream()) diff --git a/CS2MultiplayerMod/Game/MultiplayerUISystem.cs b/CS2MultiplayerMod/Game/MultiplayerUISystem.cs index a56b0d0..f09aaf0 100644 --- a/CS2MultiplayerMod/Game/MultiplayerUISystem.cs +++ b/CS2MultiplayerMod/Game/MultiplayerUISystem.cs @@ -227,6 +227,8 @@ protected override void OnCreate() () => Mod.Service != null && Mod.Service.Session.Role != SessionRole.None ? Mod.Service.SimulationSyncEnabled : Mod.Setting == null || Mod.Setting.SimulationSync)); + AddUpdateBinding(new GetterValueBinding(Group, "hostOnlySensitiveTools", + () => Mod.Setting != null && Mod.Setting.HostOnlySensitiveTools)); // Host setup edits. HostPort/HostPassword setters already refuse changes // mid-session inside Setting, so no extra guarding here. @@ -251,6 +253,8 @@ protected override void OnCreate() value => { if (Mod.Setting != null) Mod.Setting.RequireJoinApproval = value; })); AddBinding(new TriggerBinding(Group, "setSimulationSync", value => { if (Mod.Setting != null) Mod.Setting.SimulationSync = value; })); + AddBinding(new TriggerBinding(Group, "setHostOnlySensitiveTools", + value => { if (Mod.Setting != null) Mod.Setting.HostOnlySensitiveTools = value; })); AddBinding(new TriggerBinding(Group, "sendChat", value => { if (Mod.Service != null) Mod.Service.SendChatFromUi(value); })); diff --git a/CS2MultiplayerMod/Game/Sync/Infrastructure/ArchetypeEntityFactory.cs b/CS2MultiplayerMod/Game/Sync/Infrastructure/ArchetypeEntityFactory.cs new file mode 100644 index 0000000..9b13320 --- /dev/null +++ b/CS2MultiplayerMod/Game/Sync/Infrastructure/ArchetypeEntityFactory.cs @@ -0,0 +1,26 @@ +using System; +using System.Reflection; +using Unity.Entities; + +namespace CS2MultiplayerMod.Game.Sync.Infrastructure +{ + /// Calls EntityManager.CreateEntity(EntityArchetype) through reflection. + internal static class ArchetypeEntityFactory + { + private static readonly MethodInfo CreateWithArchetype = typeof(EntityManager).GetMethod( + "CreateEntity", + BindingFlags.Instance | BindingFlags.Public, + null, + new[] { typeof(EntityArchetype) }, + null); + + internal static Entity Create(EntityManager entityManager, EntityArchetype archetype) + { + if (CreateWithArchetype == null) + throw new MissingMethodException(typeof(EntityManager).FullName, + "CreateEntity(EntityArchetype)"); + + return (Entity)CreateWithArchetype.Invoke(entityManager, new object[] { archetype }); + } + } +} diff --git a/CS2MultiplayerMod/Game/Sync/Systems/Nets/NetSyncSystem/Realize.cs b/CS2MultiplayerMod/Game/Sync/Systems/Nets/NetSyncSystem/Realize.cs index 23dda8d..2d5e813 100644 --- a/CS2MultiplayerMod/Game/Sync/Systems/Nets/NetSyncSystem/Realize.cs +++ b/CS2MultiplayerMod/Game/Sync/Systems/Nets/NetSyncSystem/Realize.cs @@ -976,6 +976,10 @@ private void RealizeIncoming(MultiplayerSession session, long now) _completedNetOperations.Remember(completionKey, completedNow, 60000); SyncLog.Trace(LogTopic.Nets, "net operation committed/drained op=" + completionKey.Operation); + // Send the receipt after the native batch creates the road or quay. + if (session.Role == SessionRole.Client) + session.SendNetOperationReceipt(completionKey.Origin, + completionKey.Operation, true, "committed and drained"); }; } SyncLog.Trace(LogTopic.Nets, "net build batch armed n=" + built + diff --git a/CS2MultiplayerMod/Game/Sync/Systems/Simulation/CompanyStatsSyncSystem/Realize.cs b/CS2MultiplayerMod/Game/Sync/Systems/Simulation/CompanyStatsSyncSystem/Realize.cs index 69f43cc..727161a 100644 --- a/CS2MultiplayerMod/Game/Sync/Systems/Simulation/CompanyStatsSyncSystem/Realize.cs +++ b/CS2MultiplayerMod/Game/Sync/Systems/Simulation/CompanyStatsSyncSystem/Realize.cs @@ -1149,7 +1149,7 @@ private bool CreateCompany(Entity property, CompanyStatsEntry entry) // for it there would be a company entity with nowhere to live, so check first. if (_propertyProcessing == null || !_propertyProcessing.Enabled) return false; - Entity company = EntityManager.CreateEntity(archetype); + Entity company = ArchetypeEntityFactory.Create(EntityManager, archetype); EntityManager.SetComponentData(company, new PrefabRef { m_Prefab = prefab }); Unity.Jobs.JobHandle dependencies; diff --git a/CS2MultiplayerMod/Game/Sync/Systems/Simulation/DisasterSyncSystem.cs b/CS2MultiplayerMod/Game/Sync/Systems/Simulation/DisasterSyncSystem.cs index 6f9f29b..bd8f9ac 100644 --- a/CS2MultiplayerMod/Game/Sync/Systems/Simulation/DisasterSyncSystem.cs +++ b/CS2MultiplayerMod/Game/Sync/Systems/Simulation/DisasterSyncSystem.cs @@ -332,7 +332,7 @@ private bool Realize(DisasterEventCommand command, int originPlayerId) uint endFrame = startFrame + (uint)command.DurationFrames; EventData eventData = EntityManager.GetComponentData(prefab); - Entity entity = EntityManager.CreateEntity(eventData.m_Archetype); + Entity entity = ArchetypeEntityFactory.Create(EntityManager, eventData.m_Archetype); if (!EntityManager.HasComponent(entity) || !EntityManager.HasComponent(entity) || !HasKindComponent(entity, command.Kind)) diff --git a/CS2MultiplayerMod/Game/Sync/Systems/Simulation/ResidentialOccupancySyncSystem/RealizeCreate.cs b/CS2MultiplayerMod/Game/Sync/Systems/Simulation/ResidentialOccupancySyncSystem/RealizeCreate.cs index 0732592..0342122 100644 --- a/CS2MultiplayerMod/Game/Sync/Systems/Simulation/ResidentialOccupancySyncSystem/RealizeCreate.cs +++ b/CS2MultiplayerMod/Game/Sync/Systems/Simulation/ResidentialOccupancySyncSystem/RealizeCreate.cs @@ -4,6 +4,7 @@ using CS2MultiplayerMod.Core.Diagnostics; using CS2MultiplayerMod.Game.Diagnostics; using CS2MultiplayerMod.Game.Sync.Commands; +using CS2MultiplayerMod.Game.Sync.Infrastructure; using Game.Agents; using Game.Buildings; using Game.Citizens; @@ -34,7 +35,7 @@ private Entity CreateHousehold(Entity property, OccupancyHousehold wanted) if (!ResolvePrefab(wanted.PrefabName, out prefab, out archetype)) return Entity.Null; - Entity household = EntityManager.CreateEntity(archetype); + Entity household = ArchetypeEntityFactory.Create(EntityManager, archetype); SetOrAdd(household, new PrefabRef(prefab)); // No CurrentBuilding: that component is what asks the game to populate a household with // a randomly drawn family. The roster already says who lives here. @@ -102,7 +103,7 @@ private Entity CreateCitizen(Entity household, Entity property, OccupancyCitizen if (!TryGetCitizenCreationPrefab(out prefab, out archetype)) return Entity.Null; - Entity citizen = EntityManager.CreateEntity(archetype); + Entity citizen = ArchetypeEntityFactory.Create(EntityManager, archetype); SetOrAdd(citizen, new PrefabRef(prefab)); SetOrAdd(citizen, new HouseholdMember { m_Household = household }); SetOrAdd(citizen, new CurrentBuilding @@ -145,7 +146,7 @@ private Entity CreatePet(Entity household, Entity property, string prefabName) if (!ResolvePrefab(prefabName, out prefab, out archetype)) return Entity.Null; - Entity pet = EntityManager.CreateEntity(archetype); + Entity pet = ArchetypeEntityFactory.Create(EntityManager, archetype); SetOrAdd(pet, new PrefabRef(prefab)); SetOrAdd(pet, new HouseholdPet { m_Household = household }); SetOrAdd(pet, new CurrentBuilding @@ -171,7 +172,7 @@ private Entity CreateOwnedVehicle(Entity household, Entity source, ulong househo EntityManager.GetComponentData(prefab).m_StoppedArchetype; if (!archetype.Valid) return Entity.Null; - Entity vehicle = EntityManager.CreateEntity(archetype); + Entity vehicle = ArchetypeEntityFactory.Create(EntityManager, archetype); SetOrAdd(vehicle, EntityManager.GetComponentData(source)); SetOrAdd(vehicle, new global::Game.Vehicles.PersonalCar( diff --git a/CS2MultiplayerMod/Localization/L10n.cs b/CS2MultiplayerMod/Localization/L10n.cs index 8f5d390..408d2d1 100644 --- a/CS2MultiplayerMod/Localization/L10n.cs +++ b/CS2MultiplayerMod/Localization/L10n.cs @@ -70,6 +70,7 @@ public static class Key public const string UiTryThis = "CS2MP.UI.TryThis"; public const string UiRequireApproval = "CS2MP.UI.RequireApproval"; public const string UiSimulationSync = "CS2MP.UI.SimulationSync"; + public const string UiHostOnlySensitiveTools = "CS2MP.UI.HostOnlySensitiveTools"; public const string UiJoinRequestTitle = "CS2MP.UI.JoinRequestTitle"; // {0} = joining player's name. public const string UiJoinRequestBody = "CS2MP.UI.JoinRequestBody"; diff --git a/CS2MultiplayerMod/Localization/locales/en.properties b/CS2MultiplayerMod/Localization/locales/en.properties index 46be0a3..33961b0 100644 --- a/CS2MultiplayerMod/Localization/locales/en.properties +++ b/CS2MultiplayerMod/Localization/locales/en.properties @@ -187,6 +187,7 @@ CS2MP.UI.SendingWorld = Sending World CS2MP.UI.TryThis = Try this CS2MP.UI.RequireApproval = Approve Players CS2MP.UI.SimulationSync = Simulation Sync +CS2MP.UI.HostOnlySensitiveTools = Reserve sensitive tools for host CS2MP.UI.JoinRequestTitle = Join Request CS2MP.UI.JoinRequestBody = {0} wants to join your session. CS2MP.UI.Accept = Accept diff --git a/CS2MultiplayerMod/Mod.cs b/CS2MultiplayerMod/Mod.cs index 93897f9..a086550 100644 --- a/CS2MultiplayerMod/Mod.cs +++ b/CS2MultiplayerMod/Mod.cs @@ -73,6 +73,9 @@ public class Mod : IMod internal static string CompatibilityVersion => _compatibilityVersion ?? (_compatibilityVersion = ReleasePart(Version)); + /// Exact artifact identity for UI, session logs and peer diagnostics. + internal static string BuildId => BuildIdentity.Commit; + private static string _version; private static string _compatibilityVersion; diff --git a/CS2MultiplayerMod/Setting.cs b/CS2MultiplayerMod/Setting.cs index e680171..af59b2c 100644 --- a/CS2MultiplayerMod/Setting.cs +++ b/CS2MultiplayerMod/Setting.cs @@ -89,7 +89,7 @@ public bool IsNotHosting() public bool CannotStartHost() { return IsNotInGame() || !IsNotInSession() || - (CS2MultiplayerMod.Game.ModsCheck.AnyOtherMods && !IgnoreModCompatibilityChecks); + (CS2MultiplayerMod.Game.ModsCheck.AnyBlockingMods && !IgnoreModCompatibilityChecks); } /// @@ -222,6 +222,10 @@ public void ApplyPlatformNamePreset() [SettingsUISection(GeneralTab, StatusGroup)] public string StatusWorld => Mod.Service != null ? Mod.Service.StatusWorldText : L10n.T(L10n.Key.WorldNone); + /// Build identity shown in the settings UI. + [SettingsUISection(GeneralTab, StatusGroup)] + public string BuildIdentity => CS2MultiplayerMod.BuildIdentity.Label; + [SettingsUIButton] [SettingsUIHideByCondition(typeof(Setting), nameof(IsNotInSession))] [SettingsUISection(GeneralTab, SessionGroup)] @@ -339,6 +343,11 @@ public string HostPassword [SettingsUIHidden] public bool SimulationSync { get; set; } = true; + /// Let the host reserve terrain, policy, milestone, tile and disaster tools. + [SettingsUISection(HostTab, HostSetupGroup)] + [SettingsUIDisableByCondition(typeof(Setting), nameof(IsInSession))] + public bool HostOnlySensitiveTools { get; set; } = false; + [SettingsUISection(HostTab, HostActionGroup)] public string HostStatus => IsNotInGame() ? L10n.T(L10n.Key.HostLoadCityFirst) @@ -493,6 +502,7 @@ public override void SetDefaults() LanOnly = false; RequireJoinApproval = true; SimulationSync = true; + HostOnlySensitiveTools = false; MaxPlayers = "8"; } } diff --git a/CS2MultiplayerMod/UI/src/mods/mp-hub.tsx b/CS2MultiplayerMod/UI/src/mods/mp-hub.tsx index 0fd48ed..3f5616e 100644 --- a/CS2MultiplayerMod/UI/src/mods/mp-hub.tsx +++ b/CS2MultiplayerMod/UI/src/mods/mp-hub.tsx @@ -72,6 +72,7 @@ const LOC = { tryThis: "CS2MP.UI.TryThis", requireApproval: "CS2MP.UI.RequireApproval", simulationSync: "CS2MP.UI.SimulationSync", + hostOnlySensitiveTools: "CS2MP.UI.HostOnlySensitiveTools", joinRequestTitle: "CS2MP.UI.JoinRequestTitle", joinRequestBody: "CS2MP.UI.JoinRequestBody", accept: "CS2MP.UI.Accept", @@ -121,6 +122,7 @@ const maxPlayers$ = bindValue(GROUP, "maxPlayers", "8"); const lanOnly$ = bindValue(GROUP, "lanOnly", false); const requireApproval$ = bindValue(GROUP, "requireApproval", true); const simulationSync$ = bindValue(GROUP, "simulationSync", true); +const hostOnlySensitiveTools$ = bindValue(GROUP, "hostOnlySensitiveTools", false); const playerList$ = bindValue(GROUP, "playerList", "[]"); const pendingJoins$ = bindValue(GROUP, "pendingJoins", "[]"); const canSaveClientWorld$ = bindValue(GROUP, "canSaveClientWorld", false); @@ -139,6 +141,9 @@ interface PlayerEntry { id: number; name: string; isHost: boolean; + netStatus?: string; + latencyMs?: number; + traffic?: string; } interface PendingJoin { @@ -912,6 +917,7 @@ const SettingsFields = () => { const lanOnly = useValue(lanOnly$); const requireApproval = useValue(requireApproval$); const simulationSync = useValue(simulationSync$); + const hostOnlySensitiveTools = useValue(hostOnlySensitiveTools$); const hostConnection = useValue(hostConnection$); const sessionUsesRelay = useValue(sessionUsesRelay$); const relaySupported = useValue(relaySupported$); @@ -991,6 +997,12 @@ const SettingsFields = () => { disabled={inSession} onChange={(v) => trigger(GROUP, "setSimulationSync", v)} /> + trigger(GROUP, "setHostOnlySensitiveTools", v)} + /> ); }; @@ -1157,6 +1169,15 @@ const HostPlayerList = ({ players }: { players: PlayerEntry[] }) => { )} + {!player.isHost && player.netStatus && ( + {player.netStatus} + )} + {!player.isHost && player.latencyMs !== undefined && ( + {player.latencyMs} ms + )} + {!player.isHost && player.traffic && ( + {player.traffic} + )} ); })} @@ -1428,7 +1449,7 @@ const SessionView = ({ entries, players }: { entries: ChatEntry[]; players: Play type="text" style={styles.chatInput} value={draft} - placeholder={t(LOC.chatPlaceholder, "Type a message - /sync requests a world sync")} + placeholder={t(LOC.chatPlaceholder, "Type a message - /sync syncs, /diag saves diagnostics")} spellCheck={false} autoComplete="off" onFocus={() => setTyping(true)} diff --git a/CS2MultiplayerMod/UI/webpack.config.js b/CS2MultiplayerMod/UI/webpack.config.js index 9057ab1..d563d82 100644 --- a/CS2MultiplayerMod/UI/webpack.config.js +++ b/CS2MultiplayerMod/UI/webpack.config.js @@ -1,7 +1,6 @@ const path = require("path"); const MOD = require("./mod.json"); const MiniCssExtractPlugin = require("mini-css-extract-plugin"); -const { CSSPresencePlugin } = require("./tools/css-presence"); const TerserPlugin = require("terser-webpack-plugin"); const gray = (text) => `\x1b[90m${text}\x1b[0m`; @@ -106,7 +105,6 @@ module.exports = { }, plugins: [ new MiniCssExtractPlugin(), - new CSSPresencePlugin(), { apply(compiler) { let runCount = 0; diff --git a/help/getting-started.md b/help/getting-started.md index 259ad5a..59e821f 100644 --- a/help/getting-started.md +++ b/help/getting-started.md @@ -16,6 +16,9 @@ description: "Requirements, installing through Paradox Mods, hosting your first - A connection type both sides agree on: [Steam Relay](steam-relay.md) or a [direct connection](direct-connection.md). +When diagnosing a synchronization defect, use the clean +[multiplayer regression playset](repro-playset.md) before testing a production city. + ## Install { #install } 1. Find CS2 Multiplayer Mod on [Paradox Mods](https://mods.paradoxplaza.com/mods/150432/Windows). diff --git a/help/mods.md b/help/mods.md index 09a99c0..d912fe5 100644 --- a/help/mods.md +++ b/help/mods.md @@ -1,6 +1,14 @@ # 🧩 CS2 Multiplayer — Mod Compatibility -This list combines **official support and community multiplayer testing**. +This list combines **official support and community multiplayer testing**. Its named +entries are also classified by the in-game compatibility catalog: supported entries +can start a session, partially working entries are logged as restricted, and known +unsafe entries are blocked. A renamed or unlisted mod remains unreviewed and needs +the explicit own-risk override until it has been tested. + +Before joining, the host also compares the complete active-mod-name list from both players. +Both players must enable the same listed mods; this prevents an otherwise supported mod +from changing prefabs or simulation on only one machine. **Last updated:** September 17, 2026 diff --git a/help/repro-playset.md b/help/repro-playset.md new file mode 100644 index 0000000..7aa9050 --- /dev/null +++ b/help/repro-playset.md @@ -0,0 +1,45 @@ +--- +title: Multiplayer regression playset +description: "A clean, repeatable host/client setup for reproducing synchronization bugs." +--- + +# Multiplayer regression playset + +Use this playset before reporting or reproducing a synchronization problem. It removes +third-party assets, mods and old save state as variables, so a result identifies the +multiplayer build rather than a local setup difference. + +## Prepare both computers + +1. Update Cities: Skylines II and CS2 Multiplayer Mod to the same build. +2. Create a new Paradox Mods playset named `CS2MP Regression`. +3. Add **only** CS2 Multiplayer Mod. Do not enable maps, assets, libraries or UI mods. +4. Enable the same gameplay DLC set on both computers. Radio stations and CS1 Treasure Hunt + do not affect this test. +5. Restart the game after switching playsets, then record the `version@commit` value from the + mod's status panel in the report. + +## Use a disposable city + +The host creates a new vanilla city on a standard map, saves it as +`CS2MP-regression-YYYY-MM-DD`, then hosts with Steam Relay where possible. The client joins, +waits for the initial world sync to complete, and does not build while the world is loading. + +Do not use a production save for this test. Make a copy before testing an existing city. + +## Regression sequence + +Run one action at a time and wait until all players can see the result before the next one: + +1. Draw a straight road, a curved road and a T-junction. +2. Upgrade and then bulldoze one road segment. +3. Place a quay against water and extend it with a second segment. +4. Place a small roundabout onto an existing road connection. +5. Place a building snapped to the road, move it, then add and remove an upgrade. +6. Have the client leave and rejoin while the host city remains open. +7. Run `/sync`, then repeat the quay and roundabout cases. + +For each failed step, collect the host and affected-client logs before retrying. Include the +step number, who performed it, the `version@commit` identity, the active DLC list and whether +the host or client failed to see the result. The automatic diagnostic bundle contains the +operation and peer state needed to continue investigation. diff --git a/help/troubleshooting.md b/help/troubleshooting.md index 471b54f..5f88578 100644 --- a/help/troubleshooting.md +++ b/help/troubleshooting.md @@ -156,3 +156,10 @@ in-game screens. ## Troubleshooting by Error message Every in-game multiplayer error now includes an Open Help action that targets the relevant guide. For a searchable list of every player-facing error, warning banner, save/exit failure, and multiplayer log-warning family, see the [Error and Warning Reference](errors-and-warnings.md). + +## Send a diagnostic bundle + +Type `/diag` in the multiplayer chat after a failed join, disconnect, or desync. It writes one +`CS2MP-diagnostic-*.txt` attachment in the game's `Logs` folder. The file includes the session +snapshot, active content recorded by the mod, and the durable flight log. Automatic world recovery +and session faults write the same attachment before the next recovery step. diff --git a/mkdocs.yml b/mkdocs.yml index e2c7bd9..e8404fc 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -40,6 +40,7 @@ extra_javascript: nav: - Home: index.md - Getting started: getting-started.md + - Regression playset: repro-playset.md - Connection: - Steam Relay: steam-relay.md - Direct connection: direct-connection.md