Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 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
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
53 changes: 36 additions & 17 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 Down Expand Up @@ -174,7 +174,12 @@ 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();

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

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

Rand.PushState();
Rand.StateCompressed = randState;
Expand All @@ -195,7 +200,10 @@ 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();
Expand All @@ -208,7 +216,7 @@ 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);
}
Expand All @@ -220,6 +228,17 @@ public void ExposeData()

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 +309,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
67 changes: 59 additions & 8 deletions Source/Client/AsyncTime/AsyncWorldTimeComp.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,16 @@ public float TickRateMultiplier(TimeSpeed speed)
};
}

// Run at the speed of the fastest map or at chosen speed if there are no maps
// Run at the speed of the fastest map or at chosen speed if there are no maps.
// A map can be in Find.Maps before its AsyncTimeComp is registered (map
// generation, singleplayer conversion) - a comp-less map isn't a running map,
// so skip it rather than throw.
public TimeSpeed DesiredTimeSpeed
{
get => !Find.Maps.Any()
? timeSpeedInt
: Find.Maps.Select(m => m.AsyncTime())
.Where(a => a.ActualRateMultiplier(a.DesiredTimeSpeed) != 0f)
.Where(a => a != null && a.ActualRateMultiplier(a.DesiredTimeSpeed) != 0f)
.Max(a => a?.DesiredTimeSpeed) ?? TimeSpeed.Paused;
set => timeSpeedInt = value;
}
Expand All @@ -68,13 +71,24 @@ public TimeSpeed DesiredTimeSpeed

public int worldTicks;

// The global slot fields the world previously had no owner for. TimeSlower
// is transient in vanilla (never scribed), so a fresh instance is correct;
// gameStartAbsTick is captured at construction, which runs after
// ExposeSmallComponents has loaded the TickManager on every path
// (deserialization via SaveWorldComp, its comp-missing fallback, and the
// singleplayer conversion in HostUtil).
public TimeSlower slower = new();
public int worldGameStartAbsTick;

public AsyncWorldTimeComp(World world)
{
this.world = world;

// Use the world's constant rand seed as our initial randState.
// Only fill the seed part, leave the iterations out.
randState = (uint)world.ConstantRandSeed;

worldGameStartAbsTick = Find.TickManager?.gameStartAbsTick ?? 0;
}

public void ExposeData()
Expand All @@ -86,18 +100,34 @@ public void ExposeData()
Scribe_Values.Look(ref timeSpeedInt, "timeSpeed");
Scribe_Custom.LookULong(ref randState, "randState", 2);

TimeSpeed timeSpeed = Find.TickManager.CurTimeSpeed;
Scribe_Values.Look(ref timeSpeed, "timeSpeed");
// Read the world's own speed, not the global TickManager - the global only
// held it because PreContext used to leave it installed, so a save taken from
// a UI context would persist the viewed map's speed instead. Guarded on
// Saving: DesiredTimeSpeed walks Find.Maps, empty during LoadingVars.
// Own node: sharing "timeSpeed" with the field above made this a no-op, since
// Look resolves a label to the first matching child. timeSpeedInt is the
// default, so saves without the node fall back to the speed loaded above.
TimeSpeed globalTimeSpeed = Scribe.mode == LoadSaveMode.Saving ? DesiredTimeSpeed : timeSpeedInt;
Scribe_Values.Look(ref globalTimeSpeed, "globalTimeSpeed", timeSpeedInt);
if (Scribe.mode == LoadSaveMode.LoadingVars)
Find.TickManager.CurTimeSpeed = timeSpeed;
Find.TickManager.CurTimeSpeed = globalTimeSpeed;

if (Scribe.mode == LoadSaveMode.LoadingVars)
Multiplayer.game.worldComp = new MultiplayerWorldComp(world);

Multiplayer.game.worldComp.ExposeData();

if (Scribe.mode == LoadSaveMode.LoadingVars)
// World-basis tick stamps (CooldownClockPatches) survive reload only if
// the world clock itself does; absent node (older saves) falls back to
// the old rebuild-from-TicksGame behavior
Scribe_Values.Look(ref worldTicks, "worldTicks", -1);
if (Scribe.mode == LoadSaveMode.LoadingVars && worldTicks < 0)
worldTicks = Find.TickManager.TicksGame;

// Not scribed - always the global TickManager's value, re-derived on
// load in case the ctor ran before the meta components were current
if (Scribe.mode == LoadSaveMode.LoadingVars)
worldGameStartAbsTick = Find.TickManager.gameStartAbsTick;
}

public void Tick()
Expand All @@ -109,6 +139,13 @@ public void Tick()
{
Find.TickManager.DoSingleTick();
worldTicks++;

// PreContext installed worldTicks and DoSingleTick incremented the
// ambient, so the two can only disagree if something else moved one
// of them - which would silently shift every world-clock read
if (MpVersion.IsDebug && Find.TickManager.ticksGameInt != worldTicks)
Log.Error($"MP: world clock mismatch: ambient {Find.TickManager.ticksGameInt} != worldTicks {worldTicks}");

Multiplayer.WorldComp.TickWorldSessions();

if (ModsConfig.BiotechActive)
Expand Down Expand Up @@ -141,9 +178,18 @@ public void Tick()
}
}

// The world's clock lives on the global TickManager only while installed
// here; the world tick is self-contained. PreContext installs the full
// world snapshot - ticksGameInt = worldTicks, the scribed mirror that
// DoSingleTick's increment tracks - and PostContext restores whatever the
// frame had. Readers that want world time between ticks (letters, alerts,
// world render, saving) install it explicitly. A stack because world
// commands can nest a world context inside the world tick.
private readonly Stack<TimeSnapshot?> prevTimes = new();

public void PreContext()
{
Find.TickManager.CurTimeSpeed = DesiredTimeSpeed;
prevTimes.Push(TimeSnapshot.GetAndSetFromWorld());
Rand.PushState();
Rand.StateCompressed = randState;

Expand All @@ -166,6 +212,11 @@ public void PostContext()

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

if (prevTimes.Count == 0)
Log.Error("MP: unbalanced PostContext on the world clock");
else
prevTimes.Pop()?.Set();
}

public void ExecuteCmd(ScheduledCommand cmd)
Expand Down Expand Up @@ -252,7 +303,7 @@ public void ExecuteCmd(ScheduledCommand cmd)
}
catch (Exception e)
{
Log.Error($"World cmd exception ({cmdType}): {e}");
SimulationFailures.Handle($"World cmd exception ({cmdType})", e);
}
finally
{
Expand Down
Loading