Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions Defs/KeyBindings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,10 @@
<defaultKeyCodeA>Keypad0</defaultKeyCodeA>
</KeyBindingDef>

<!-- No default key: the menu is reachable from the wheel + in-game UI, so an opt-in hotkey avoids colliding with mod-added bindings. -->
<KeyBindingDef ParentName="MultiplayerKeyBinding">
<defName>MpTogglePingMenu</defName>
<label>toggle ping menu</label>
</KeyBindingDef>

</Defs>
83 changes: 83 additions & 0 deletions Defs/MultiplayerPingDefs.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
<?xml version="1.0" encoding="utf-8" ?>

<Defs>

<!--
Built-in ping categories. <isDefault>true</isDefault> marks the singleton "no category picked"
entry that lives in the wheel's centre - mods should not flag a second def as default. The
<order> values leave gaps of 100 so mods can slot themselves between vanilla entries without
everyone having to renumber.

Labels and descriptions resolve through the standard DefInjected localization path; the
rwmt/Multiplayer-Locale repo owns translations.
-->

<Multiplayer.Client.MultiplayerPingDef>
<defName>MpPing_Default</defName>
<label>Ping</label>
<description>Plain attention ping. No category selected.</description>
<isDefault>true</isDefault>
<order>0</order>
</Multiplayer.Client.MultiplayerPingDef>

<Multiplayer.Client.MultiplayerPingDef>
<defName>MpPing_Attack</defName>
<label>Attack</label>
<description>Call an attack here.</description>
<order>100</order>
<tint>(1, 0.25, 0.25)</tint>
<iconPath>UI/Commands/AttackMelee</iconPath>
<iconScale>1.20</iconScale>
<glyph>A</glyph>
<soundDefName>Quest_Failed</soundDefName>
</Multiplayer.Client.MultiplayerPingDef>

<Multiplayer.Client.MultiplayerPingDef>
<defName>MpPing_Defend</defName>
<label>Defend</label>
<description>Hold this position.</description>
<order>200</order>
<tint>(0.4, 0.6, 1)</tint>
<iconPath>UI/Designators/HomeAreaOn</iconPath>
<iconScale>0.92</iconScale>
<glyph>D</glyph>
<soundDefName>DraftOn</soundDefName>
</Multiplayer.Client.MultiplayerPingDef>

<Multiplayer.Client.MultiplayerPingDef>
<defName>MpPing_Help</defName>
<label>Help</label>
<description>Request assistance.</description>
<order>300</order>
<tint>(1, 0.95, 0.3)</tint>
<iconPath>UI/Commands/AsMedical</iconPath>
<iconScale>1.00</iconScale>
<glyph>+</glyph>
<soundDefName>TutorMessageAppear</soundDefName>
</Multiplayer.Client.MultiplayerPingDef>

<Multiplayer.Client.MultiplayerPingDef>
<defName>MpPing_Loot</defName>
<label>Loot</label>
<description>Items or resources to grab.</description>
<order>400</order>
<tint>(0.4, 1, 0.4)</tint>
<iconPath>UI/Buttons/TradeMode</iconPath>
<iconScale>1.06</iconScale>
<glyph>L</glyph>
<soundDefName>ExecuteTrade</soundDefName>
</Multiplayer.Client.MultiplayerPingDef>

<Multiplayer.Client.MultiplayerPingDef>
<defName>MpPing_Rally</defName>
<label>Rally</label>
<description>Meet up here.</description>
<order>500</order>
<tint>(0.85, 0.55, 1)</tint>
<iconPath>UI/Commands/GatherSpotActive</iconPath>
<iconScale>0.95</iconScale>
<glyph>R</glyph>
<soundDefName>Quest_Accepted</soundDefName>
</Multiplayer.Client.MultiplayerPingDef>

</Defs>
89 changes: 88 additions & 1 deletion Source/Client/Comp/Game/MultiplayerGameComp.cs
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<int, List<PingInfo>> 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<PingInfo> cachedAllMarkers = new();
private int cachedAllMarkersVersion = -1;

public IReadOnlyList<PingInfo> AllMarkers
{
get
{
if (cachedAllMarkersVersion != markersVersion)
{
cachedAllMarkers.Clear();
foreach (var bucket in markersByFaction.Values)
cachedAllMarkers.AddRange(bucket);
cachedAllMarkersVersion = markersVersion;
}
return cachedAllMarkers;
}
}

public List<PingInfo> GetOrCreateFactionMarkers(int factionLoadId)
{
if (!markersByFaction.TryGetValue(factionLoadId, out var bucket))
{
bucket = new List<PingInfo>();
markersByFaction[factionLoadId] = bucket;
}
return bucket;
}

public bool IsLowestWins => timeControl == TimeControl.LowestWins;

public PlayerData LocalPlayerDataOrNull => playerData.GetValueOrDefault(Multiplayer.session.playerId);
Expand All @@ -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<PingInfo> 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<int, List<PingInfo>>();
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");
Expand All @@ -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<Dictionary<int, PlayerData>>(reader);
DebugSettings.godMode = LocalPlayerDataOrNull?.godMode ?? false;

var markersFlat = SyncSerialization.ReadSync<List<PingInfo>>(reader);
// Negative ids would alias with legacy markerId == 0 rows.
nextMarkerId = Math.Max(0, SyncSerialization.ReadSync<int>(reader));
markerCapPerPlayer = PingMarkerCap.Clamp(SyncSerialization.ReadSync<int>(reader));

// Session data is fresher than the autosave the joiner just loaded - overwrite.
markersByFaction = new SortedDictionary<int, List<PingInfo>>();
if (markersFlat != null)
foreach (var m in markersFlat)
GetOrCreateFactionMarkers(m.placedByFactionLoadId).Add(m);
markersVersion++;
}

[SyncMethod(debugOnly = true)]
Expand All @@ -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
Expand Down
18 changes: 15 additions & 3 deletions Source/Client/Desyncs/SaveableDesyncInfo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,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);
Expand All @@ -71,9 +72,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()
Expand Down Expand Up @@ -106,6 +109,12 @@ 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";
Comment on lines +113 to +116
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Do we even want to add this info to the desync report? Not sure how this is useful

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I originally added these for debugging marker specific desyncs during dev. The idea was that if you have two players with different Marker Count or Next Marker Id in the desync report, you immediately know the divergence happened somewhere in the marker path. The "markerCap" was mainly just there for me to make sure that the marker cap set by the host is properly syncing to all clients. The info doesn't really provide any other info regarding desyncs so I fully understand if you'd prefer it removed.


desyncInfo
.AppendLine("###Tick Data###")
.AppendLine($"Arbiter Connected And Playing|||{Multiplayer.session.ArbiterPlaying}")
Expand All @@ -123,6 +132,9 @@ 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###CPU Info###")
.AppendLine($"Processor Name|||{SystemInfo.processorType}")
.AppendLine($"Processor Speed (MHz)|||{SystemInfo.processorFrequency}")
Expand Down
20 changes: 20 additions & 0 deletions Source/Client/Multiplayer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<PingLabelWindow>()?.Close(false);
ws.WindowOfType<PingMenuWindow>()?.Close(false);
ws.WindowOfType<PingFiltersDialog>()?.Close(false);
ws.WindowOfType<PingHostSettingsDialog>()?.Close(false);
}

if (session != null)
{
session.Stop();
Expand Down
10 changes: 10 additions & 0 deletions Source/Client/MultiplayerGame.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<PingLabelWindow>()?.Close(false);
}
}
}
}
Loading
Loading