diff --git a/Defs/KeyBindings.xml b/Defs/KeyBindings.xml
index 8c98be508..29c6cba02 100644
--- a/Defs/KeyBindings.xml
+++ b/Defs/KeyBindings.xml
@@ -31,4 +31,10 @@
Keypad0
+
+
+ MpTogglePingMenu
+
+
+
\ No newline at end of file
diff --git a/Source/Client/Comp/Game/MultiplayerGameComp.cs b/Source/Client/Comp/Game/MultiplayerGameComp.cs
index 2f3909cc7..73c29512f 100644
--- a/Source/Client/Comp/Game/MultiplayerGameComp.cs
+++ b/Source/Client/Comp/Game/MultiplayerGameComp.cs
@@ -1,9 +1,12 @@
-using System.Collections.Generic;
+using System;
+using System.Collections.Generic;
using System.Linq;
using HarmonyLib;
using Multiplayer.API;
using Multiplayer.Client.Saving;
using Multiplayer.Common;
+using Multiplayer.Common.Networking.Packet;
+using UnityEngine;
using Verse;
namespace Multiplayer.Client.Comp
@@ -21,6 +24,45 @@ public class MultiplayerGameComp : IExposable, IHasSessionData
public string idBlockBase64;
+ // Bucketed by placer faction loadID; SortedDictionary guarantees identical enumeration on every client.
+ public SortedDictionary> markersByFaction = new();
+ public int nextMarkerId;
+
+ // Bumped on every markersByFaction mutation; read by PingMenuWindow's row cache. Runtime-only.
+ public int markersVersion;
+
+ // Host-authoritative, copied from ServerSettings at game start - every client must agree (drives FIFO eviction).
+ public int markerCapPerPlayer = PingMarkerCap.Default;
+
+ // Materialized merge of markersByFaction.Values; rebuilt on markersVersion change.
+ private readonly List cachedAllMarkers = new();
+ private int cachedAllMarkersVersion = -1;
+
+ public IReadOnlyList AllMarkers
+ {
+ get
+ {
+ if (cachedAllMarkersVersion != markersVersion)
+ {
+ cachedAllMarkers.Clear();
+ foreach (var bucket in markersByFaction.Values)
+ cachedAllMarkers.AddRange(bucket);
+ cachedAllMarkersVersion = markersVersion;
+ }
+ return cachedAllMarkers;
+ }
+ }
+
+ public List GetOrCreateFactionMarkers(int factionLoadId)
+ {
+ if (!markersByFaction.TryGetValue(factionLoadId, out var bucket))
+ {
+ bucket = new List();
+ markersByFaction[factionLoadId] = bucket;
+ }
+ return bucket;
+ }
+
public bool IsLowestWins => timeControl == TimeControl.LowestWins;
public PlayerData LocalPlayerDataOrNull => playerData.GetValueOrDefault(Multiplayer.session.playerId);
@@ -35,6 +77,27 @@ public void ExposeData()
Scribe_Values.Look(ref timeControl, "timeControl");
Scribe_Values.Look(ref nextSessionId, "nextSessionId");
+ // Re-bucket must run in LoadingVars - Scribe_Collections.Look only populates the ref then.
+ List markersFlat = Scribe.mode == LoadSaveMode.Saving ? AllMarkers.ToList() : null;
+ Scribe_Collections.Look(ref markersFlat, "mpMarkers", LookMode.Deep);
+ if (Scribe.mode == LoadSaveMode.LoadingVars)
+ {
+ // Always drop stale select-times; loaded ids may overlap last-session marker ids.
+ LocationPings.DropStaleSelectTimes();
+ if (markersFlat != null)
+ {
+ markersByFaction = new SortedDictionary>();
+ foreach (var m in markersFlat)
+ GetOrCreateFactionMarkers(m.placedByFactionLoadId).Add(m);
+ markersVersion++;
+ }
+ }
+ Scribe_Values.Look(ref nextMarkerId, "mpNextMarkerId");
+ Scribe_Values.Look(ref markerCapPerPlayer, "mpMarkerCapPerPlayer", PingMarkerCap.Default);
+ if (Scribe.mode == LoadSaveMode.LoadingVars)
+ // Hand-edited saves can land out-of-range values.
+ markerCapPerPlayer = PingMarkerCap.Clamp(markerCapPerPlayer);
+
// Store for back-compat conversion in GameExposeComponentsPatch
if (Scribe.mode == LoadSaveMode.LoadingVars)
Scribe_Values.Look(ref idBlockBase64, "globalIdBlock");
@@ -43,12 +106,30 @@ public void ExposeData()
public void WriteSessionData(ByteWriter writer)
{
SyncSerialization.WriteSync(writer, playerData);
+
+ // Joiner needs host's live marker list, not the autosave - markers placed between
+ // save and join would otherwise be missing, and nextMarkerId would diverge.
+ SyncSerialization.WriteSync(writer, AllMarkers.ToList());
+ SyncSerialization.WriteSync(writer, Math.Max(0, nextMarkerId));
+ SyncSerialization.WriteSync(writer, PingMarkerCap.Clamp(markerCapPerPlayer));
}
public void ReadSessionData(ByteReader reader)
{
playerData = SyncSerialization.ReadSync>(reader);
DebugSettings.godMode = LocalPlayerDataOrNull?.godMode ?? false;
+
+ var markersFlat = SyncSerialization.ReadSync>(reader);
+ // Negative ids would alias with legacy markerId == 0 rows.
+ nextMarkerId = Math.Max(0, SyncSerialization.ReadSync(reader));
+ markerCapPerPlayer = PingMarkerCap.Clamp(SyncSerialization.ReadSync(reader));
+
+ // Session data is fresher than the autosave the joiner just loaded - overwrite.
+ markersByFaction = new SortedDictionary>();
+ if (markersFlat != null)
+ foreach (var m in markersFlat)
+ GetOrCreateFactionMarkers(m.placedByFactionLoadId).Add(m);
+ markersVersion++;
}
[SyncMethod(debugOnly = true)]
@@ -57,6 +138,12 @@ public void SetGodMode(int playerId, bool godMode)
playerData[playerId].godMode = godMode;
}
+ [SyncMethod]
+ public void SetMarkerCapPerPlayer(int newCap)
+ {
+ markerCapPerPlayer = PingMarkerCap.Clamp(newCap);
+ }
+
public TimeSpeed GetLowestTimeVote(int tickableId, bool excludePaused = false)
{
return (TimeSpeed)playerData.Values
diff --git a/Source/Client/Debug/DebugActions.cs b/Source/Client/Debug/DebugActions.cs
index 46d05147b..b98e4f02a 100644
--- a/Source/Client/Debug/DebugActions.cs
+++ b/Source/Client/Debug/DebugActions.cs
@@ -231,7 +231,8 @@ public static void ShowDesync()
{
Find.WindowStack.Add(new DesyncedWindow(
"Debug action",
- new SaveableDesyncInfo(Multiplayer.game.sync, new ClientSyncOpinion(0), new ClientSyncOpinion(0), 0, true)
+ new SaveableDesyncInfo(Multiplayer.game.sync, new ClientSyncOpinion(0), new ClientSyncOpinion(0), 0, true,
+ new SaveableDesyncInfo.SnapshotFreshness(IsFresh: false, ElapsedMs: 0, SnapshotTick: -1, DesyncTick: -1, FallbackReason: "debug action - no live snapshot"))
));
}
diff --git a/Source/Client/Desyncs/SaveableDesyncInfo.cs b/Source/Client/Desyncs/SaveableDesyncInfo.cs
index 200c3dfee..148d90111 100644
--- a/Source/Client/Desyncs/SaveableDesyncInfo.cs
+++ b/Source/Client/Desyncs/SaveableDesyncInfo.cs
@@ -22,11 +22,13 @@ public class SaveableDesyncInfo(
ClientSyncOpinion local,
ClientSyncOpinion remote,
int diffAt,
- bool diffAtFound)
+ bool diffAtFound,
+ SaveableDesyncInfo.SnapshotFreshness snapshotFreshness)
{
public readonly ClientSyncOpinion local = local;
public readonly ClientSyncOpinion remote = remote;
public readonly int diffAt = diffAt;
+ public readonly SnapshotFreshness snapshotFreshness = snapshotFreshness;
private readonly Task metadata = Task.Run(MetadataGenerator.Generate);
private readonly Task replay = Task.Run(SaveReplayIfApplicable);
@@ -60,9 +62,10 @@ public void Save([CanBeNull] HostInfo hostInfo)
}
catch (AggregateException e)
{
- if (e.InnerExceptions.SingleOrDefault(inner => inner is TaskCanceledException) == null) throw;
+ if (!e.InnerExceptions.Any(inner => inner is TaskCanceledException)) throw;
}
- if (replay.IsCompletedSuccessfully) {
+ if (replay.IsCompletedSuccessfully)
+ {
var replayFile = replay.Result;
zip.CreateEntryFromFile(replayFile.FullName, "replay.rwmts", CompressionLevel.NoCompression);
DeleteFileSilent(replayFile);
@@ -71,9 +74,11 @@ public void Save([CanBeNull] HostInfo hostInfo)
catch (Exception e)
{
Log.Error($"Exception writing desync info: {e}");
+ if (replay.IsCompletedSuccessfully)
+ DeleteFileSilent(replay.Result);
}
- Log.Message($"Desync info writing took {watch.ElapsedMilliseconds}");
+ Log.Message($"Desync info writing took {watch.ElapsedMilliseconds} ms");
}
private string GetLocalTraces()
@@ -106,6 +111,17 @@ private string GetDesyncDetails()
{
var desyncInfo = new StringBuilder();
+ // gameComp can be null if the session was torn down between desync and Save click.
+ var comp = Multiplayer.game?.gameComp;
+ var markerCount = comp?.AllMarkers.Count.ToStringSafe() ?? "n/a";
+ var nextMarkerId = comp?.nextMarkerId.ToStringSafe() ?? "n/a";
+ var markerCap = comp?.markerCapPerPlayer.ToStringSafe() ?? "n/a";
+
+ // SnapshotTick == -1 means we have no snapshot to compare against (e.g. DebugActions.ShowDesync).
+ var lag = snapshotFreshness.SnapshotTick >= 0
+ ? (snapshotFreshness.DesyncTick - snapshotFreshness.SnapshotTick).ToStringSafe()
+ : "n/a";
+
desyncInfo
.AppendLine("###Tick Data###")
.AppendLine($"Arbiter Connected And Playing|||{Multiplayer.session.ArbiterPlaying}")
@@ -123,6 +139,16 @@ private string GetDesyncDetails()
.AppendLine($"Async time active|||{Multiplayer.GameComp.asyncTime}")
.AppendLine($"Multifaction active|||{Multiplayer.GameComp.multifaction}")
.AppendLine($"Map Count|||{Find.Maps?.Count.ToStringSafe()}")
+ .AppendLine($"Marker Count|||{markerCount}")
+ .AppendLine($"Next Marker Id|||{nextMarkerId}")
+ .AppendLine($"Marker Cap Per Player|||{markerCap}")
+ .AppendLine("\n###Replay Snapshot Freshness###")
+ .AppendLine($"Snapshot Tick|||{snapshotFreshness.SnapshotTick}")
+ .AppendLine($"Desync Tick|||{snapshotFreshness.DesyncTick}")
+ .AppendLine($"Snapshot Lag Ticks|||{lag}")
+ .AppendLine($"Refresh Succeeded|||{snapshotFreshness.IsFresh}")
+ .AppendLine($"Refresh Elapsed (ms)|||{snapshotFreshness.ElapsedMs}")
+ .AppendLine($"Fallback Reason|||{snapshotFreshness.FallbackReason ?? "n/a"}")
.AppendLine("\n###CPU Info###")
.AppendLine($"Processor Name|||{SystemInfo.processorType}")
.AppendLine($"Processor Speed (MHz)|||{SystemInfo.processorFrequency}")
@@ -196,4 +222,7 @@ private static void DeleteFileSilent(FileInfo file)
}
public record HostInfo([CanBeNull] string Traces, [CanBeNull] string JittedMethods);
+
+ // SnapshotTick == DesyncTick when refresh succeeded; otherwise stale (autosave-aligned).
+ public record SnapshotFreshness(bool IsFresh, long ElapsedMs, int SnapshotTick, int DesyncTick, [CanBeNull] string FallbackReason);
}
diff --git a/Source/Client/Desyncs/SyncCoordinator.cs b/Source/Client/Desyncs/SyncCoordinator.cs
index 0714065d5..3f7943c73 100644
--- a/Source/Client/Desyncs/SyncCoordinator.cs
+++ b/Source/Client/Desyncs/SyncCoordinator.cs
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
+using System.Diagnostics;
using System.Linq;
using Multiplayer.Client.Desyncs;
using Multiplayer.Client.Util;
@@ -128,13 +129,60 @@ private void HandleDesync(ClientSyncOpinion oldOpinion, ClientSyncOpinion newOpi
var diffAt = FindTraceHashesDiffTick(local, remote, out var found);
Multiplayer.Client.Send(new ClientDesyncedPacket(local.startTick, diffAt));
+ var snapshotInfo = TryRefreshSnapshotForDesync();
+
MpUI.ClearWindowStack();
Find.WindowStack.Add(new DesyncedWindow(
desyncMessage,
- new SaveableDesyncInfo(this, local, remote, diffAt, found)
+ new SaveableDesyncInfo(this, local, remote, diffAt, found, snapshotInfo)
));
}
+ // Refresh dataSnapshot synchronously on the main thread so the embedded replay.rwmts
+ // captures the divergent tick instead of the (default 5 min stale) autosave-aligned join
+ // point. SaveGameData isn't thread-safe (Scribe, Find.Maps). No SendGameData - peers must
+ // not see a join-point broadcast spawned by a desync. If the capture overruns the budget
+ // below we keep the stale snapshot, since we can't predict the cost ahead of time.
+ private const int SnapshotRefreshBudgetMs = 2000;
+
+ private static SaveableDesyncInfo.SnapshotFreshness TryRefreshSnapshotForDesync()
+ {
+ var staleAt = Multiplayer.session.dataSnapshot?.CachedAtTime ?? -1;
+ var nowTick = TickPatch.Timer;
+ var watch = Stopwatch.StartNew();
+
+ try
+ {
+ var fresh = Replay.CaptureLocalSnapshot();
+ watch.Stop();
+
+ if (watch.ElapsedMilliseconds > SnapshotRefreshBudgetMs)
+ {
+ Log.Warning(
+ $"[MP] Desync snapshot capture took {watch.ElapsedMilliseconds} ms " +
+ $"(budget {SnapshotRefreshBudgetMs} ms); keeping stale snapshot from tick {staleAt}.");
+ return new SaveableDesyncInfo.SnapshotFreshness(
+ IsFresh: false, ElapsedMs: watch.ElapsedMilliseconds, SnapshotTick: staleAt,
+ DesyncTick: nowTick, FallbackReason: $"capture exceeded {SnapshotRefreshBudgetMs} ms budget");
+ }
+
+ Multiplayer.session.dataSnapshot = fresh;
+ return new SaveableDesyncInfo.SnapshotFreshness(
+ IsFresh: true, ElapsedMs: watch.ElapsedMilliseconds, SnapshotTick: fresh.CachedAtTime,
+ DesyncTick: nowTick, FallbackReason: null);
+ }
+ catch (Exception e)
+ {
+ watch.Stop();
+ Log.Warning(
+ $"[MP] Desync snapshot capture threw after {watch.ElapsedMilliseconds} ms; " +
+ $"keeping stale snapshot from tick {staleAt}. Exception: {e.Message}");
+ return new SaveableDesyncInfo.SnapshotFreshness(
+ IsFresh: false, ElapsedMs: watch.ElapsedMilliseconds, SnapshotTick: staleAt,
+ DesyncTick: nowTick, FallbackReason: $"exception: {e.GetType().Name}: {e.Message}");
+ }
+ }
+
private static int FindTraceHashesDiffTick(ClientSyncOpinion local, ClientSyncOpinion remote, out bool found)
{
found = true;
diff --git a/Source/Client/EarlyInit.cs b/Source/Client/EarlyInit.cs
index 35d89bb7e..af0795ea9 100644
--- a/Source/Client/EarlyInit.cs
+++ b/Source/Client/EarlyInit.cs
@@ -59,6 +59,10 @@ internal static void InitSync()
internal static void LatePatches()
{
+ // Inject runtime translation keys (e.g. MarkerInspectTab labelKey) into the active language.
+ // Needed for keys that travel through vanilla code paths which hardcode .Translate().
+ PingRuntimeTranslations.Register();
+
if (MpVersion.IsDebug)
Log.Message("== Structure == \n" + SyncDict.syncWorkers.PrintStructure());
}
diff --git a/Source/Client/Multiplayer.cs b/Source/Client/Multiplayer.cs
index 5ea4b1760..88da627bc 100644
--- a/Source/Client/Multiplayer.cs
+++ b/Source/Client/Multiplayer.cs
@@ -210,6 +210,26 @@ public static void StopMultiplayer()
OnMainThread.ClearScheduled();
LongEventHandler.ClearQueuedEvents();
+ // Faction loadIDs are per-world; mutes by loadID would silently apply to unrelated
+ // factions in the next session. Username mutes survive - usernames are stable.
+ if (settings is { } s && s.hiddenFactionLoadIds is { Count: > 0 })
+ {
+ s.hiddenFactionLoadIds.Clear();
+ MultiplayerLoader.Multiplayer.instance?.WriteSettings();
+ }
+ // PingInfo instances die with the session; sweep vanilla's static selectTimes dict.
+ LocationPings.DropStaleSelectTimes();
+ // Close ping windows individually so each runs PostClose (rect persistence).
+ // ClearWindowStack bypasses PostClose and their OnGUI dereferences soon-dead state.
+ var ws = Find.WindowStack;
+ if (ws != null)
+ {
+ ws.WindowOfType()?.Close(false);
+ ws.WindowOfType()?.Close(false);
+ ws.WindowOfType()?.Close(false);
+ ws.WindowOfType()?.Close(false);
+ }
+
if (session != null)
{
session.Stop();
diff --git a/Source/Client/MultiplayerGame.cs b/Source/Client/MultiplayerGame.cs
index ae9f3431d..784e0b798 100644
--- a/Source/Client/MultiplayerGame.cs
+++ b/Source/Client/MultiplayerGame.cs
@@ -142,6 +142,16 @@ public void ChangeRealPlayerFaction(Faction newFaction, bool regenMapDrawers = t
Find.MainTabsRoot?.EscapeCurrentTab();
Find.ColonistBar?.MarkColonistsDirty();
+
+ // regenMapDrawers:false is the internal SaveAndReload faction-switch (transient).
+ // Only close the rename modal and drop the gizmo cache on a real switch.
+ if (regenMapDrawers)
+ {
+ var loc = Multiplayer.session?.locationPings;
+ if (loc != null)
+ loc.cachedGizmos = null; // Next BuildGizmos rebuilds the key alongside.
+ Find.WindowStack?.WindowOfType()?.Close(false);
+ }
}
}
}
diff --git a/Source/Client/MultiplayerStatic.cs b/Source/Client/MultiplayerStatic.cs
index 49519f9b9..92e08e1ab 100644
--- a/Source/Client/MultiplayerStatic.cs
+++ b/Source/Client/MultiplayerStatic.cs
@@ -28,9 +28,325 @@ public static class MultiplayerStatic
{
public static KeyBindingDef ToggleChatDef = KeyBindingDef.Named("MpToggleChat");
public static KeyBindingDef PingKeyDef = KeyBindingDef.Named("MpPingKey");
+ // No default bind - user assigns via Keyboard Config.
+ public static KeyBindingDef TogglePingMenuDef = KeyBindingDef.Named("MpTogglePingMenu");
public static readonly Texture2D PingBase = ContentFinder.Get("Multiplayer/PingBase");
public static readonly Texture2D PingPin = ContentFinder.Get("Multiplayer/PingPin");
+
+ // Procedural, antialiased, white - tint at draw time.
+ public static readonly Texture2D PingCircle = MakeCircleTex(256, outerRadius: 127.5f, innerRadius: 0f);
+ public static readonly Texture2D PingRing = MakeCircleTex(256, outerRadius: 127.5f, innerRadius: 108f);
+
+ // Pre-rotated wheel sector textures live next to LocationPings.WheelOptions so the slot
+ // count can't desync - see LocationPings.PingSectors / PingSectorArcs.
+ public static readonly Texture2D PingChevronUp = MakeChevronUpTex(64);
+
+ // reportFailure=false so a missing path returns null and the renderer falls back to Glyph().
+ public static readonly Texture2D PingIconAttack = ContentFinder.Get("UI/Commands/AttackMelee", false);
+ public static readonly Texture2D PingIconDefend = ContentFinder.Get("UI/Designators/HomeAreaOn", false);
+ public static readonly Texture2D PingIconHelp = ContentFinder.Get("UI/Commands/AsMedical", false);
+ public static readonly Texture2D PingIconLoot = ContentFinder.Get("UI/Buttons/TradeMode", false);
+ public static readonly Texture2D PingIconRally = ContentFinder.Get("UI/Commands/GatherSpotActive", false);
+
+ // Gizmo action icons reuse vanilla UI/ atlases (visibility toggles, reset arrows).
+ public static readonly Texture2D PingHideForMeIcon = ContentFinder.Get("UI/Designators/PlanHide");
+ public static readonly Texture2D PingShowForMeIcon = ContentFinder.Get("UI/Designators/PlanOn");
+ public static readonly Texture2D PingResetViewIcon = ContentFinder.Get("UI/Commands/TempReset");
+ // Procedural - half-faded disc for the transparency gizmo.
+ public static readonly Texture2D PingTransparencyIcon = MakeFadeDiscTex(128);
+ // Procedural - selection corners with central X for the deselect gizmo.
+ public static readonly Texture2D PingDeselectIcon = MakeDeselectTex(128);
+ // Procedural - speaker + knockout slash. Shared by all mute actions; the label carries the distinction.
+ public static readonly Texture2D PingMuteIcon = MakeMuteTex(128);
+
+ private static Texture2D MakeCircleTex(int size, float outerRadius, float innerRadius)
+ {
+ var tex = new Texture2D(size, size, TextureFormat.RGBA32, false);
+ var pixels = new Color32[size * size];
+ var center = (size - 1) / 2f;
+
+ for (int y = 0; y < size; y++)
+ {
+ for (int x = 0; x < size; x++)
+ {
+ var dx = x - center;
+ var dy = y - center;
+ var d = Mathf.Sqrt(dx * dx + dy * dy);
+
+ float alpha;
+ if (innerRadius <= 0f)
+ {
+ alpha = Mathf.Clamp01(outerRadius - d + 0.5f);
+ }
+ else
+ {
+ var inA = Mathf.Clamp01(d - innerRadius + 0.5f);
+ var outA = Mathf.Clamp01(outerRadius - d + 0.5f);
+ alpha = Mathf.Min(inA, outA);
+ }
+
+ pixels[y * size + x] = new Color32(255, 255, 255, (byte)(alpha * 255f));
+ }
+ }
+
+ tex.SetPixels32(pixels);
+ tex.filterMode = FilterMode.Bilinear;
+ tex.wrapMode = TextureWrapMode.Clamp;
+ tex.Apply();
+ return tex;
+ }
+
+ // Annular sector with axis at centerAngleDeg clockwise from screen-up, half-width halfAngleDeg.
+ // Convention: high py = top of rect on screen, so +dy is "screen up" here.
+ internal static Texture2D MakeSectorTex(int size, float outerRadius, float innerRadius, float halfAngleDeg, float centerAngleDeg)
+ {
+ var tex = new Texture2D(size, size, TextureFormat.RGBA32, false);
+ var pixels = new Color32[size * size];
+ var center = (size - 1) / 2f;
+ var halfA = halfAngleDeg * Mathf.Deg2Rad;
+ var centerA = centerAngleDeg * Mathf.Deg2Rad;
+
+ // Outward normals of the right/left radial boundary lines (+x right, +y up).
+ var cosR = Mathf.Cos(centerA + halfA);
+ var sinR = Mathf.Sin(centerA + halfA);
+ var cosL = Mathf.Cos(centerA - halfA);
+ var sinL = Mathf.Sin(centerA - halfA);
+
+ for (int py = 0; py < size; py++)
+ {
+ for (int px = 0; px < size; px++)
+ {
+ var dx = px - center;
+ var dy = py - center;
+ var d = Mathf.Sqrt(dx * dx + dy * dy);
+
+ var inA = Mathf.Clamp01(d - innerRadius + 0.5f);
+ var outA = Mathf.Clamp01(outerRadius - d + 0.5f);
+ var radialAlpha = Mathf.Min(inA, outA);
+
+ var dRight = dx * cosR - dy * sinR;
+ var dLeft = -dx * cosL + dy * sinL;
+ var angularAlpha = Mathf.Clamp01(0.5f - Mathf.Max(dRight, dLeft));
+
+ var alpha = radialAlpha * angularAlpha;
+ pixels[py * size + px] = new Color32(255, 255, 255, (byte)(alpha * 255f));
+ }
+ }
+
+ tex.SetPixels32(pixels);
+ tex.filterMode = FilterMode.Bilinear;
+ tex.wrapMode = TextureWrapMode.Clamp;
+ tex.Apply();
+ return tex;
+ }
+
+ // Distance-to-line field with AA band so the texture scales cleanly without re-baking.
+ // Apex (V's point) at HIGH py, arm ends at LOW py - matches MakeSectorTex convention.
+ private static Texture2D MakeChevronUpTex(int size)
+ {
+ var tex = new Texture2D(size, size, TextureFormat.RGBA32, false);
+ var pixels = new Color32[size * size];
+ var center = (size - 1) / 2f;
+ var strokeHalf = size * 0.10f;
+ var apexY = size * 0.78f;
+ var armEndY = size * 0.22f;
+ var armEndDx = size * 0.36f;
+
+ for (int py = 0; py < size; py++)
+ {
+ for (int px = 0; px < size; px++)
+ {
+ var dx = px - center;
+ var dy = py;
+
+ var distR = DistToSegment(dx, dy, 0f, apexY, armEndDx, armEndY);
+ var distL = DistToSegment(dx, dy, 0f, apexY, -armEndDx, armEndY);
+ var d = Mathf.Min(distR, distL);
+ var alpha = Mathf.Clamp01(strokeHalf - d + 0.5f);
+
+ pixels[py * size + px] = new Color32(255, 255, 255, (byte)(alpha * 255f));
+ }
+ }
+
+ tex.SetPixels32(pixels);
+ tex.filterMode = FilterMode.Bilinear;
+ tex.wrapMode = TextureWrapMode.Clamp;
+ tex.Apply();
+ return tex;
+ }
+
+ // Disc with horizontal alpha gradient - opaque on the left half, fading to ~15% on the right.
+ private static Texture2D MakeFadeDiscTex(int size)
+ {
+ var tex = new Texture2D(size, size, TextureFormat.RGBA32, false);
+ var pixels = new Color32[size * size];
+ var center = (size - 1) / 2f;
+ var outerRadius = size * 0.46f;
+
+ for (int y = 0; y < size; y++)
+ {
+ for (int x = 0; x < size; x++)
+ {
+ var dx = x - center;
+ var dy = y - center;
+ var d = Mathf.Sqrt(dx * dx + dy * dy);
+ var circleAlpha = Mathf.Clamp01(outerRadius - d + 0.5f);
+
+ // Left edge (x=0) opaque, right edge (x=size-1) at minAlpha.
+ var t = (float)x / (size - 1);
+ var horizontalAlpha = Mathf.Lerp(1f, 0.18f, t);
+
+ var alpha = circleAlpha * horizontalAlpha;
+ pixels[y * size + x] = new Color32(255, 255, 255, (byte)(alpha * 255f));
+ }
+ }
+
+ tex.SetPixels32(pixels);
+ tex.filterMode = FilterMode.Bilinear;
+ tex.wrapMode = TextureWrapMode.Clamp;
+ tex.Apply();
+ return tex;
+ }
+
+ // Selection-corner brackets at the four corners + a central X.
+ private static Texture2D MakeDeselectTex(int size)
+ {
+ var tex = new Texture2D(size, size, TextureFormat.RGBA32, false);
+ var pixels = new Color32[size * size];
+ var center = (size - 1) / 2f;
+
+ var inset = size * 0.14f;
+ var armLen = size * 0.22f;
+ var bracketStroke = size * 0.085f;
+ var xHalf = size * 0.16f;
+ var xStroke = size * 0.085f;
+
+ float far = (size - 1) - inset;
+
+ for (int py = 0; py < size; py++)
+ {
+ for (int px = 0; px < size; px++)
+ {
+ // 8 L-arm segments - horizontal + vertical at each of the 4 corners.
+ var d = float.MaxValue;
+ d = Mathf.Min(d, DistToSegment(px, py, inset, inset, inset + armLen, inset));
+ d = Mathf.Min(d, DistToSegment(px, py, inset, inset, inset, inset + armLen));
+ d = Mathf.Min(d, DistToSegment(px, py, far, inset, far - armLen, inset));
+ d = Mathf.Min(d, DistToSegment(px, py, far, inset, far, inset + armLen));
+ d = Mathf.Min(d, DistToSegment(px, py, inset, far, inset + armLen, far));
+ d = Mathf.Min(d, DistToSegment(px, py, inset, far, inset, far - armLen));
+ d = Mathf.Min(d, DistToSegment(px, py, far, far, far - armLen, far));
+ d = Mathf.Min(d, DistToSegment(px, py, far, far, far, far - armLen));
+ var bracketAlpha = Mathf.Clamp01(bracketStroke - d + 0.5f);
+
+ var dXa = DistToSegment(px, py, center - xHalf, center - xHalf, center + xHalf, center + xHalf);
+ var dXb = DistToSegment(px, py, center - xHalf, center + xHalf, center + xHalf, center - xHalf);
+ var xAlpha = Mathf.Clamp01(xStroke - Mathf.Min(dXa, dXb) + 0.5f);
+
+ var alpha = Mathf.Max(bracketAlpha, xAlpha);
+ pixels[py * size + px] = new Color32(255, 255, 255, (byte)(alpha * 255f));
+ }
+ }
+
+ tex.SetPixels32(pixels);
+ tex.filterMode = FilterMode.Bilinear;
+ tex.wrapMode = TextureWrapMode.Clamp;
+ tex.Apply();
+ return tex;
+ }
+
+ // Speaker (rectangular stand + trapezoidal horn) + two sound arcs + diagonal slash.
+ // Slash is drawn with a knockout band so it reads against the speaker body (which is
+ // also white) at gizmo scale.
+ private static Texture2D MakeMuteTex(int size)
+ {
+ var tex = new Texture2D(size, size, TextureFormat.RGBA32, false);
+ var pixels = new Color32[size * size];
+ var center = (size - 1) / 2f;
+
+ // Speaker geometry.
+ var standLeft = size * 0.18f;
+ var standRight = size * 0.34f;
+ var standTop = size * 0.42f;
+ var standBottom = size * 0.58f;
+
+ var hornNarrowX = standRight;
+ var hornWideX = size * 0.56f;
+ var hornNarrowHalfH = (standBottom - standTop) / 2f;
+ var hornWideHalfH = size * 0.22f;
+
+ // Sound arcs.
+ var arcCenterX = size * 0.56f;
+ var arcCenterY = center;
+ var arcR1 = size * 0.11f;
+ var arcR2 = size * 0.21f;
+ var arcStroke = size * 0.055f;
+
+ // Slash: from upper-right to lower-left. Knockout band carves the speaker so the
+ // slash itself reads as a dark gap with a thin white line through it.
+ var slashAx = size * 0.93f;
+ var slashAy = size * 0.07f;
+ var slashBx = size * 0.07f;
+ var slashBy = size * 0.93f;
+ var slashGap = size * 0.075f;
+ var slashLine = size * 0.035f;
+
+ for (int py = 0; py < size; py++)
+ {
+ for (int px = 0; px < size; px++)
+ {
+ float a = 0f;
+
+ if (px >= standLeft && px <= standRight && py >= standTop && py <= standBottom)
+ a = 1f;
+
+ if (px >= hornNarrowX && px <= hornWideX)
+ {
+ var t = (px - hornNarrowX) / Mathf.Max(0.0001f, hornWideX - hornNarrowX);
+ var halfH = Mathf.Lerp(hornNarrowHalfH, hornWideHalfH, t);
+ if (Mathf.Abs(py - center) <= halfH) a = 1f;
+ }
+
+ var rdx = px - arcCenterX;
+ var rdy = py - arcCenterY;
+ if (rdx > 0f)
+ {
+ var rd = Mathf.Sqrt(rdx * rdx + rdy * rdy);
+ var wedge = Mathf.Abs(rdy) <= rdx ? 1f : 0f;
+ var arc1 = Mathf.Clamp01(arcStroke - Mathf.Abs(rd - arcR1) + 0.5f);
+ var arc2 = Mathf.Clamp01(arcStroke - Mathf.Abs(rd - arcR2) + 0.5f);
+ a = Mathf.Max(a, Mathf.Max(arc1, arc2) * wedge);
+ }
+
+ var slashD = DistToSegment(px, py, slashAx, slashAy, slashBx, slashBy);
+ if (slashD < slashGap) a = 0f;
+ var slashAlpha = Mathf.Clamp01(slashLine - slashD + 0.5f);
+ a = Mathf.Max(a, slashAlpha);
+
+ pixels[py * size + px] = new Color32(255, 255, 255, (byte)(a * 255f));
+ }
+ }
+
+ tex.SetPixels32(pixels);
+ tex.filterMode = FilterMode.Bilinear;
+ tex.wrapMode = TextureWrapMode.Clamp;
+ tex.Apply();
+ return tex;
+ }
+
+ private static float DistToSegment(float px, float py, float ax, float ay, float bx, float by)
+ {
+ var dx = bx - ax;
+ var dy = by - ay;
+ var len2 = dx * dx + dy * dy;
+ if (len2 < 1e-6f) return Mathf.Sqrt((px - ax) * (px - ax) + (py - ay) * (py - ay));
+ var t = Mathf.Clamp01(((px - ax) * dx + (py - ay) * dy) / len2);
+ var qx = ax + t * dx;
+ var qy = ay + t * dy;
+ return Mathf.Sqrt((px - qx) * (px - qx) + (py - qy) * (py - qy));
+ }
+
public static readonly Texture2D WebsiteIcon = ContentFinder.Get("Multiplayer/Website");
public static readonly Texture2D DiscordIcon = ContentFinder.Get("Multiplayer/Discord");
public static readonly Texture2D Pulse = ContentFinder.Get("Multiplayer/Pulse");
diff --git a/Source/Client/Networking/HostUtil.cs b/Source/Client/Networking/HostUtil.cs
index 0b736217e..8ee961f7c 100644
--- a/Source/Client/Networking/HostUtil.cs
+++ b/Source/Client/Networking/HostUtil.cs
@@ -9,6 +9,7 @@
using Multiplayer.Client.Networking;
using Multiplayer.Client.Util;
using Multiplayer.Common;
+using Multiplayer.Common.Networking.Packet;
using RimWorld;
using UnityEngine;
using Verse;
@@ -99,6 +100,7 @@ private static void SetGameState(ServerSettings settings)
Multiplayer.GameComp.asyncTime = settings.asyncTime;
Multiplayer.GameComp.multifaction = settings.multifaction;
+ Multiplayer.GameComp.markerCapPerPlayer = PingMarkerCap.Clamp(settings.markerCapPerPlayer);
Multiplayer.GameComp.debugMode = settings.debugMode;
Multiplayer.GameComp.logDesyncTraces = settings.desyncTraces;
Multiplayer.GameComp.pauseOnLetter = settings.pauseOnLetter;
diff --git a/Source/Client/Networking/State/ClientPlayingState.cs b/Source/Client/Networking/State/ClientPlayingState.cs
index da91769d8..b8cc1fc94 100644
--- a/Source/Client/Networking/State/ClientPlayingState.cs
+++ b/Source/Client/Networking/State/ClientPlayingState.cs
@@ -135,6 +135,15 @@ public void HandleSelected(ServerSelectedPacket packet)
[TypedPacketHandler]
public void HandlePing(ServerPingLocPacket packet) => Session.locationPings.ReceivePing(packet);
+ [TypedPacketHandler]
+ public void HandleClearMarkers(ServerClearMarkersPacket packet) => Session.locationPings.ReceiveClearMarkers(packet);
+
+ [TypedPacketHandler]
+ public void HandleDeleteMarker(ServerDeleteMarkerPacket packet) => Session.locationPings.ReceiveDeleteMarker(packet);
+
+ [TypedPacketHandler]
+ public void HandleRenameMarker(ServerRenameMarkerPacket packet) => Session.locationPings.ReceiveRenameMarker(packet);
+
[PacketHandler(Packets.Server_MapResponse, allowFragmented: true)]
public void HandleMapResponse(ByteReader data)
{
diff --git a/Source/Client/Patches/Pings.cs b/Source/Client/Patches/Pings.cs
new file mode 100644
index 000000000..22816eb53
--- /dev/null
+++ b/Source/Client/Patches/Pings.cs
@@ -0,0 +1,406 @@
+using System;
+using System.Collections.Generic;
+using System.Reflection;
+using HarmonyLib;
+using Multiplayer.Client.Util;
+using RimWorld;
+using RimWorld.Planet;
+using UnityEngine;
+using Verse;
+using Verse.Sound;
+
+namespace Multiplayer.Client
+{
+ // Click-on-marker selection + armed placement.
+ [HarmonyPatch(typeof(MapInterface), nameof(MapInterface.HandleMapClicks))]
+ static class PingMapClickPatch
+ {
+ [HarmonyPriority(MpPriority.MpFirst)]
+ static bool Prefix()
+ {
+ if (Multiplayer.Client == null || Multiplayer.arbiterInstance || TickPatch.Simulating) return true;
+ if (Find.CurrentMap == null) return true;
+ // CurrentMap stays non-null on planet view - without this, armed LMB projects onto the hidden map camera.
+ if (WorldRendererUtility.WorldSelected) return true;
+
+ if (Find.DesignatorManager?.SelectedDesignator != null) return true;
+ if ((Find.Targeter?.IsTargeting ?? false)
+ || (Find.WorldTargeter?.IsTargeting ?? false)) return true;
+
+ var ev = Event.current;
+ var loc = Multiplayer.session?.locationPings;
+ if (loc == null) return true;
+ var mouse = UI.MousePositionOnUIInverted;
+ var size = LocationPings.OnScreenPingSize;
+
+ var onWheelUi = IsMouseOverWheelOrDrawer(loc, mouse);
+
+ // Swallow LMB on the wheel so vanilla doesn't world-select behind it.
+ if (ev.type == EventType.MouseDown && ev.button == 0 && onWheelUi)
+ {
+ ev.Use();
+ return false;
+ }
+
+ if (ev.type == EventType.MouseDown && ev.button == 1 && loc.armedCategory != null && !onWheelUi)
+ {
+ loc.DisarmPlacement();
+ ev.Use();
+ return false;
+ }
+
+ if (ev.type == EventType.MouseDown && ev.button == 0 && loc.armedCategory != null && !onWheelUi)
+ {
+ var mapLoc = UI.MouseMapPosition();
+ if (loc.FireArmedAtMap(Find.CurrentMap.uniqueID, PlanetTile.Invalid, mapLoc))
+ {
+ ev.Use();
+ return false;
+ }
+ }
+
+ // Double-click on MouseDown is safe (no drag-box); single-click lives in PingSelectUnderMousePatch on MouseUp.
+ if (ev.type == EventType.MouseDown && ev.button == 0 && ev.clickCount == 2)
+ {
+ if (TryHitTest(loc, mouse, size, out var hit) && hit.isMarker)
+ {
+ if (!Selector.ShiftIsHeld)
+ Find.Selector?.ClearSelection();
+ SelectAllMatchingMarkersOnScreen(loc, hit);
+ SoundDefOf.Click.PlayOneShotOnCamera();
+ ev.Use();
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ private static void SelectAllMatchingMarkersOnScreen(LocationPings loc, PingInfo hit)
+ {
+ loc.ClearSelection();
+
+ var screenRect = new Rect(0f, 0f, UI.screenWidth, UI.screenHeight);
+ var mapId = Find.CurrentMap.uniqueID;
+
+ foreach (var m in loc.Markers)
+ {
+ if (m.mapId != mapId) continue;
+ if (m.category != hit.category) continue;
+ if (!screenRect.Contains(m.mapLoc.MapToUIPosition())) continue;
+ loc.SelectInfo(m, additive: true);
+ }
+ }
+
+ internal static bool IsMouseOverWheelOrDrawer(LocationPings loc, Vector2 mouse)
+ {
+ // Wheel area is only "over UI" while up - otherwise the last wheel position would block marker clicks forever.
+ if (loc.wheelActive)
+ {
+ var dx = mouse.x - loc.wheelScreenOrigin.x;
+ var dy = mouse.y - loc.wheelScreenOrigin.y;
+ const float ChevronStackH = 32f;
+ if (Mathf.Abs(dx) <= LocationPings.WheelBackdropR
+ && dy >= -(LocationPings.WheelBackdropR + ChevronStackH)
+ && dy <= LocationPings.WheelBackdropR)
+ return true;
+ }
+
+ // PingInspectPane intentionally excluded; its body click is a no-op and markers under it must stay clickable.
+ var w = Find.WindowStack?.GetWindowAt(mouse);
+ return w is PingMenuWindow or PingFiltersDialog or PingHostSettingsDialog;
+ }
+
+ internal static bool TryHitTest(LocationPings loc, Vector2 mouse, float size, out PingInfo hit)
+ {
+ var mapId = Find.CurrentMap.uniqueID;
+
+ foreach (var m in loc.Markers)
+ {
+ if (m.mapId != mapId) continue;
+ if (!m.IsVisible()) continue;
+ if (HitMarker(m, mouse, size)) { hit = m; return true; }
+ }
+ for (var i = loc.pings.Count - 1; i >= 0; i--)
+ {
+ var p = loc.pings[i];
+ if (p.mapId != mapId) continue;
+ if (p.PlayerInfo == null) continue;
+ if (!p.IsVisible()) continue;
+ if (HitMarker(p, mouse, size)) { hit = p; return true; }
+ }
+
+ hit = null;
+ return false;
+ }
+
+ // DrawAt anchors the pin above mapLoc; a circle test at mapLoc would miss the pin.
+ private static bool HitMarker(PingInfo info, Vector2 mouse, float size)
+ {
+ var screen = info.mapLoc.MapToUIPosition();
+ var halfW = size * 0.6f;
+ if (Math.Abs(mouse.x - screen.x) > halfW) return false;
+ var pinTop = screen.y - size - info.y * size;
+ var ringBot = screen.y + size * 0.6f;
+ return mouse.y >= pinTop && mouse.y <= ringBot;
+ }
+ }
+
+ // Inject marker-action gizmos - DrawGizmoGridFor accepts any Gizmo in selectedObjects.
+ [HarmonyPatch(typeof(GizmoGridDrawer), nameof(GizmoGridDrawer.DrawGizmoGridFor))]
+ static class PingGizmoInjectPatch
+ {
+ [HarmonyPriority(MpPriority.MpLast)]
+ static void Prefix(ref IEnumerable