Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
7df46f3
Desync fixes, multifaction routing, and failure surfacing
CormacOConnor72 Jul 19, 2026
b85c35d
Fix ideo reconstruction, bossgroup cooldown, and pause-grace resets
CormacOConnor72 Jul 19, 2026
4c424d3
Fix live-session desync causes and quest routing
CormacOConnor72 Jul 19, 2026
496bfcc
Sync VNPE gizmo actions, add unsynced-spawn diagnostic, fix download …
CormacOConnor72 Jul 19, 2026
432d2be
Improve desync forensics: log tail capture, join-diff recording
CormacOConnor72 Jul 19, 2026
95317aa
Multifaction: repeat objective zone/designation cleanup, owner-filter…
CormacOConnor72 Jul 19, 2026
f7ea49a
Multifaction: faction-context and routing fixes across DLC systems
CormacOConnor72 Jul 19, 2026
815cedb
Attribute wastepack events, fix async season desync, determinism and …
CormacOConnor72 Jul 20, 2026
6a7123e
Add Turn It On and Off compat, gate spectator home check from sim, fi…
CormacOConnor72 Jul 21, 2026
fd3fa34
Multifaction: per-pair NPC goodwill and shared-ideo pitfall fixes
CormacOConnor72 Jul 21, 2026
13bd053
Move mod compat patches to the mod-compat branch
CormacOConnor72 Jul 21, 2026
f844dd3
Fix Steam persona names over 32 UTF-8 bytes killing the session on pl…
CormacOConnor72 Jul 22, 2026
a11a779
Merge rwmt dev
CormacOConnor72 Jul 22, 2026
37a4bf8
Fix async-time cooldown skew: normalize shuttle, ability and daily-sk…
CormacOConnor72 Jul 22, 2026
d6ab4cb
Fix paused maps reading a foreign time speed under async time
CormacOConnor72 Jul 25, 2026
28cd967
Don't read the viewer-dependent time speed when creating a map's time…
CormacOConnor72 Jul 25, 2026
95ba621
Fix the world's saved time speed scribing to a colliding label
CormacOConnor72 Jul 25, 2026
1d7ca1a
Fix hosting from a singleplayer save and non-async map generation NREs
CormacOConnor72 Jul 27, 2026
d6ba37f
Merge rwmt dev
CormacOConnor72 Jul 27, 2026
c9b4d57
Pin SourceGen's Roslyn reference to 4.13.0 so SDK 9 builds work (#970)
CormacOConnor72 Jul 27, 2026
84de515
Fix multifaction cross-faction bleed in global counters and pickers
CormacOConnor72 Jul 27, 2026
341b31e
Fix the async-time clock split that flickers the UI on a paused client
CormacOConnor72 Jul 27, 2026
d521e99
Collapse item-accessibility rescans deterministically instead of alwa…
CormacOConnor72 Jul 27, 2026
213d222
Fix the update-rate stutter after joins and rehosts
CormacOConnor72 Jul 28, 2026
ae16b71
Handle playing-state broadcasts on clients that are still loading
CormacOConnor72 Jul 28, 2026
b1fc85e
Fix comp-less map NRE storm; derive VTR counts from a synced view table
CormacOConnor72 Jul 31, 2026
b672556
Fix cross-clock UI/sound artifacts at mixed speeds
CormacOConnor72 Jul 31, 2026
f8faaef
Fix faction data residue corrupting UI reads after in-bracket exceptions
CormacOConnor72 Aug 1, 2026
e2909ea
Fix missing victory letter after caravan battles under multifaction.
M-r-A Aug 9, 2026
f407e74
Merge pull request #1 from romangr/fix/caravans-battlefield-owner-win…
CormacOConnor72 Aug 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 73 additions & 23 deletions Source/Client/AsyncTime/AsyncTimeComp.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using System.Collections.Generic;
using HarmonyLib;
using Multiplayer.Client.Comp;
using Multiplayer.Client.Desyncs;
using Multiplayer.Client.Factions;
using Multiplayer.Client.Patches;
using Multiplayer.Client.Saving;
Expand Down Expand Up @@ -64,18 +65,17 @@ public TimeSpeed DesiredTimeSpeed

public int TickableId => map.uniqueID;

public int GameStartAbsTick
{
get
{
if (gameStartAbsTickMap == 0)
{
gameStartAbsTickMap = Find.TickManager?.gameStartAbsTick ?? 0;
}

return gameStartAbsTickMap;
}
}
// Pure: this is read from UI-reachable context installs
// (TimeSnapshot.GetAndSetFromMap), and a read that wrote the ambient
// global into the scribed field made render order and viewer identity
// inputs to TicksAbs - an RNG seed in pawn think trees
// (RandSeedForHour), wind and sky glow - so a render bug could become
// a desync and a save diff. The scribed field is resolved once in
// FinalizeInit instead; the fallback covers reads before that point
// without writing anything.
public int GameStartAbsTick => gameStartAbsTickMap != 0
? gameStartAbsTickMap
: Find.TickManager?.gameStartAbsTick ?? 0;

public Map map;
public int mapTicks;
Expand All @@ -95,7 +95,31 @@ public int GameStartAbsTick

public Queue<ScheduledCommand> cmds = new();

public int CurrentPlayerCount { get; private set; }
private int cachedPlayerCount;
private int cachedPlayerCountVersion = -1;

// Derived from the synced view table on demand, never incremented.
// The version gate makes the count self-healing: comps recreated by a
// reload start stale and re-derive on first read, so there is no
// window where a viewed map sits at the no-viewer rate.
public int CurrentPlayerCount
{
get
{
var gameComp = Multiplayer.GameComp;
if (cachedPlayerCountVersion != gameComp.playerViewsVersion)
{
cachedPlayerCount = 0;
foreach (var viewedMapId in gameComp.playerViewedMaps.Values)
if (viewedMapId == map.uniqueID)
cachedPlayerCount++;
cachedPlayerCountVersion = gameComp.playerViewsVersion;
}

return cachedPlayerCount;
}
}

public int VTR => CurrentPlayerCount > 0 ? VTRSync.MinimumVtr : VTRSync.MaximumVtr;

public AsyncTimeComp(Map map, int gameStartAbsTick = 0)
Expand Down Expand Up @@ -174,7 +198,17 @@ public void UpdateManagers()
map.glowGrid.GlowGridUpdate_First();
}

private TimeSnapshot? prevTime;
// Contexts nest on the same comp by design (the quest brackets in
// MultiplayerAsyncQuest run inside Tick(), SetContextForAccept inside
// ExecuteCmd), so the restore state is a stack like the Rand and
// faction state around it: a single field let an inner bracket clobber
// the outer snapshot, leaking the inner clock for the rest of the frame.
private readonly Stack<TimeSnapshot?> prevTimes = new();

// Expected faction stack depth per nested bracket, recorded at
// PreContext exit - PostContext unwinds any entries stranded above it
// by an exception before doing its own pop (see UnwindFactionStack)
private readonly Stack<int> prevFactionDepths = new();

public void PreContext()
{
Expand All @@ -184,7 +218,9 @@ public void PreContext()
: Multiplayer.WorldComp.spectatorFaction,
force: true);

prevTime = TimeSnapshot.GetAndSetFromMap(map);
prevFactionDepths.Push(FactionContext.stack.Count);

prevTimes.Push(TimeSnapshot.GetAndSetFromMap(map));

Rand.PushState();
Rand.StateCompressed = randState;
Expand All @@ -195,11 +231,19 @@ public void PreContext()

public void PostContext()
{
prevTime?.Set();
if (prevTimes.Count == 0)
Log.Error($"MP: unbalanced PostContext on {this}");
else
prevTimes.Pop()?.Set();

randState = Rand.StateCompressed;
Rand.PopState();

if (prevFactionDepths.Count == 0)
Log.Error($"MP: unbalanced faction depth on {this}");
else
FactionExtensions.UnwindFactionStack(map, prevFactionDepths.Pop(), $"map {map.uniqueID} bracket");

map.PopFaction();
}

Expand All @@ -208,18 +252,24 @@ public void ExposeData()
Scribe_Values.Look(ref mapTicks, "mapTicks");
Scribe_Values.Look(ref timeSpeedInt, "timeSpeed");

Scribe_Values.Look(ref gameStartAbsTickMap, "gameStartAbsTickMap");
Scribe_Values.Look(ref gameStartAbsTickMap, "gameStartAbsTickMap", 0);

Scribe_Custom.LookULong(ref randState, "randState", 1);
}

public int IncreasePlayerCount() => CurrentPlayerCount += 1;
// This should never go below 0, this is just defensive programming. Hopefully not needed anymore, but
// nevertheless still left.
public int DecreasePlayerCount() => CurrentPlayerCount = Math.Max(0, CurrentPlayerCount - 1);

public void FinalizeInit()
{
// Only saves predating the per-map field (3bfa487) and the
// comp-missing error path in SavingPatches can still hold 0 here.
// Both are load-time, and this runs at a fixed point of the load
// path on every client (Map.FinalizeLoading -> Map.FinalizeInit ->
// MapComponentUtility.FinalizeInit), where the ambient
// gameStartAbsTick is the scribed global - the correct value for
// those saves. Normal play never constructs a comp with 0
// (MapSetup always passes a non-zero source).
if (gameStartAbsTickMap == 0)
gameStartAbsTickMap = Find.TickManager?.gameStartAbsTick ?? 0;

cmds = new Queue<ScheduledCommand>(
Multiplayer.session.dataSnapshot?.MapCmds.GetValueSafe(map.uniqueID) ?? new List<ScheduledCommand>()
);
Expand Down Expand Up @@ -290,7 +340,7 @@ public void ExecuteCmd(ScheduledCommand cmd)
}
catch (Exception e)
{
MpLog.Error($"Map cmd exception ({cmdType}): {e}");
SimulationFailures.Handle($"Map cmd exception ({cmdType})", e);
}
finally
{
Expand Down
85 changes: 74 additions & 11 deletions Source/Client/AsyncTime/AsyncTimePatches.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using System.Reflection;
using HarmonyLib;
using Multiplayer.Client.Factions;
using Multiplayer.Client.Util;
using RimWorld;
using RimWorld.Planet;
using Verse;
Expand Down Expand Up @@ -50,26 +51,87 @@ static IEnumerable<MethodBase> TargetMethods()
static bool Prefix() => Multiplayer.Client == null || !MapUpdateMarker.updating;
}

// Season notifications. The old patch pushed Multiplayer.RealPlayerFaction
// and that client's own faction's map clock into a method that runs in the
// synced world tick and writes scribed state (lastSeason) - per-client
// inputs in the deterministic sim. Under async, clients' chosen maps cross
// season boundaries at different world ticks and lastSeason diverges (the
// "summer arrived twice" desync); a spectating client diverged even in
// single-faction games. Replaced with a per-faction pass over synced state
// only: each ownable player faction, in deterministic order, gets its
// season computed from ITS min-timezone home map's async clock, with its
// own scribed lastSeason (FactionWorldData) and its letters routed by the
// pushed context. Identical inputs on every client by construction.
[HarmonyPatch(typeof(DateNotifier), nameof(DateNotifier.DateNotifierTick))]
static class DateNotifierPatch
{
static void Prefix(DateNotifier __instance, ref int? __state)
static bool Prefix(DateNotifier __instance)
{
if (Multiplayer.Client == null && Multiplayer.RealPlayerFaction != null) return;
if (Multiplayer.Client == null)
return true;

Map map = __instance.FindPlayerHomeWithMinTimezone();
if (map == null) return;
foreach (var kv in Multiplayer.WorldComp.factionData)
{
var faction = Find.FactionManager.GetById(kv.Key);
if (!QuestFactionOwnership.IsOwnablePlayerFaction(faction))
continue;

((Map)null).PushFaction(faction);
try
{
TickForFaction(__instance, kv.Value);
}
finally
{
FactionExtensions.PopFaction();
}
}

__state = Find.TickManager.TicksGame;
FactionContext.Push(Multiplayer.RealPlayerFaction);
Find.TickManager.DebugSetTicksGame(map.AsyncTime().mapTicks);
return false;
}

static void Finalizer(int? __state)
// Vanilla DateNotifierTick body (1.6.4871) against per-faction state,
// under the faction's context and its map's clock
static void TickForFaction(DateNotifier notifier, FactionWorldData data)
{
if (!__state.HasValue) return;
Find.TickManager.DebugSetTicksGame(__state.Value);
FactionContext.Pop();
// First run on an existing save: adopt the global pre-fix state so
// the transition already seen isn't re-announced
if (data.lastSeason == Season.Undefined && notifier.lastSeason != Season.Undefined)
data.lastSeason = notifier.lastSeason;

Map map = notifier.FindPlayerHomeWithMinTimezone();

int prevTicks = Find.TickManager.TicksGame;
if (map != null)
Find.TickManager.DebugSetTicksGame(map.AsyncTime().mapTicks);

try
{
float latitude = map != null ? Find.WorldGrid.LongLatOf(map.Tile).y : 0f;
float longitude = map != null ? Find.WorldGrid.LongLatOf(map.Tile).x : 0f;
Season season = GenDate.Season(Find.TickManager.TicksAbs, latitude, longitude);

if (season == data.lastSeason ||
(data.lastSeason != Season.Undefined && season == data.lastSeason.GetPreviousSeason()))
return;

if (data.lastSeason != Season.Undefined && notifier.AnyPlayerHomeSeasonsAreMeaningful())
{
if (GenDate.YearsPassed == 0 && season == Season.Summer &&
notifier.AnyPlayerHomeAvgTempIsLowInWinter())
Find.LetterStack.ReceiveLetter("LetterLabelFirstSummerWarning".Translate(),
"FirstSummerWarning".Translate(), LetterDefOf.NeutralEvent);
else if (GenDate.DaysPassed > 5)
Messages.Message("MessageSeasonBegun".Translate(season.Label()).CapitalizeFirst(),
MessageTypeDefOf.NeutralEvent);
}

data.lastSeason = season;
}
finally
{
Find.TickManager.DebugSetTicksGame(prevTicks);
}
}
}

Expand Down Expand Up @@ -149,6 +211,7 @@ static void Postfix(ref bool __result)
if (Multiplayer.Client == null) return;
if (WorldRendererUtility.WorldSelected) return;
if (FactionCreator.generatingMap) return;
if (Find.CurrentMap == null) return;

var asyncTime = Find.CurrentMap.AsyncTime();
var timeSpeed = Multiplayer.IsReplay ? TickPatch.replayTimeSpeed : asyncTime.DesiredTimeSpeed;
Expand Down
Loading