Skip to content

Replay debug actions under the acting player's world view - #976

Open
M-r-A wants to merge 1 commit into
rwmt:devfrom
romangr:pr/debug-action-world-view
Open

Replay debug actions under the acting player's world view#976
M-r-A wants to merge 1 commit into
rwmt:devfrom
romangr:pr/debug-action-world-view

Conversation

@M-r-A

@M-r-A M-r-A commented Aug 5, 2026

Copy link
Copy Markdown

Firing a debug action desyncs the game whenever two players are looking at
different screens.

Root cause

DebugSync replays a debug action on every client by re-running it from a synced
node path. It reproduces the cursor position and the selected object so that
actions depending on them behave the same everywhere — but not which view the
acting player had open
.

Vanilla's incident action derives its entire target from exactly that:

// Verse.DebugActionsIncidents.GetTarget()
IIncidentTarget target = WorldRendererUtility.WorldSelected
    ? Find.WorldSelector.SingleSelectedObject as IIncidentTarget
    : null;
if (target == null && WorldRendererUtility.WorldSelected) target = Find.World;
if (target == null) target = Find.CurrentMap;

WorldRendererUtility.WorldSelected is client-local camera state. So a host on
the planet with a caravan selected resolves Caravan, while a client looking at
a colony resolves Find.CurrentMap.

That breaks the replay twice over:

  1. Wrong target. The same command aims at different things on each client.
  2. Silent non-execution. The node's label is built from the target's name
    (labelGetter = () => name + " (" + GetIncidentTargetLabel() + ")..."), so
    the two clients disagree about the label. RecreateGraphAndGetNode matches
    children by LabelAndCategory(), finds nothing, returns null, and the
    caller skips execution. The command is accepted, acknowledged, and quietly
    never runs on that client — with no log line where it happens.

A synchronized command that runs on some clients and not others is about the most
direct way to desync a lockstep simulation, and this one leaves no trace at the
point of failure, which is why the desync surfaces later somewhere unrelated.

Host — planet view Client — colony view
WorldSelected true false
GetTarget() the selected Caravan Find.CurrentMap
Node label Do incident (Caravan Rim)... Do incident (Map)...
RecreateGraphAndGetNode found null
Outcome runs, consumes RNG does nothing

Evidence

Firing GiveQuest_EndGame_ShipEscape with the host on the planet and the client
on a colony map. Both traces are the first entry in their capture, i.e. the
divergence itself rather than its aftermath.

Host — executing the incident inside the synced command:

at RimWorld.UniqueIDsManager.GetNextQuestID ()
at RimWorld.Quest.MakeRaw ()
at RimWorld.QuestGen.QuestGen.InitializeQuestGen (RimWorld.QuestScriptDef, Slate)
at RimWorld.QuestUtility.GenerateQuestAndMakeAvailable (RimWorld.QuestScriptDef, single)
at RimWorld.IncidentWorker_GiveQuest.GiveQuest (RimWorld.IncidentParms, RimWorld.QuestScriptDef)
at RimWorld.IncidentWorker_GiveQuest.TryExecuteWorker (RimWorld.IncidentParms)
at RimWorld.IncidentWorker.TryExecute (RimWorld.IncidentWorker, RimWorld.IncidentParms)
at Verse.DebugActionsIncidents/<>c__DisplayClass7_1.<GetIncidentDebugAction>b__3 ()
at Multiplayer.Client.DebugActionNodeEnter/MpDebugAction.Action ()          DebugSync.cs:302
at Multiplayer.Client.DebugSync.HandleCmd (Multiplayer.Common.ByteReader)   DebugSync.cs:124
at Multiplayer.Client.AsyncTime.AsyncWorldTimeComp.ExecuteCmd (ScheduledCommand)

Client, same trace index — never entered the command at all, just ticking:

at Verse.Rand.MTBEventOccurs (single, single, single)
at Verse.HediffGiver_RandomAgeCurved.OnIntervalPassed (Verse.Pawn, Verse.Hediff)
at Verse.Pawn_HealthTracker.HealthTickInterval (Verse.Pawn_HealthTracker, int)
at Verse.Pawn.TickInterval (Verse.Pawn, int)
at Verse.Thing.DoTick ()
at Verse.TickList.Tick ()
at Multiplayer.Client.AsyncTimeComp.Tick ()                    AsyncTimeComp.cs:158
at Multiplayer.Client.TickPatch.TickTickable (ITickable)       TickPatch.cs:264
at Multiplayer.Client.TickPatch.DoTick (bool&)                 TickPatch.cs:243

The same signature appears with CaravanMeeting, diverging slightly earlier —
while the debug action is still computing its parameters, before the worker runs:

at Verse.FloatRange.get_RandomInRange ()
at RimWorld.StorytellerUtility.DefaultThreatPointsNow (RimWorld.IIncidentTarget)
at RimWorld.StorytellerUtility.DefaultParmsNow (IncidentCategoryDef, IIncidentTarget)
at Verse.DebugActionsIncidents/<>c__DisplayClass7_1.<GetIncidentDebugAction>b__3 ()
at Multiplayer.Client.DebugActionNodeEnter/MpDebugAction.Action ()          DebugSync.cs:302
at Multiplayer.Client.DebugSync.HandleCmd (Multiplayer.Common.ByteReader)   DebugSync.cs:124

The fix

Carry the flag in the debug command and override the getter during replay,
following the MouseCellPatch / MouseTilePatch pattern already used for the
cursor:

  • WorldSelectedPatch — getter postfix with a nullable result, placed beside
    the existing cursor overrides.
  • SendCmd writes WorldRendererUtility.WorldSelected.
  • HandleCmd reads it before rebuilding the node graph (labels depend on it)
    and clears it in the same finally block as the cursor overrides, so it cannot
    leak into normal play.

Verification

Two connected clients, asyncTime off, multifaction off:

  • Host on the planet with a caravan selected, client on a colony — the
    original repro. Fires at the caravan on both. No desync.
  • Both on the planet, nothing selected — target resolves to Find.World on
    both. No desync.
  • GiveQuest_EndGame_ShipEscape from the world view — the trace above. Quest
    letter arrives on host and client. No desync.

Build clean, 158/158 tests (no test-visible surface; the harness cannot construct
RimWorld UI types).

Notes

  • The debug command's wire format gains one byte. Host and clients need
    matching builds, which this mod already requires.
  • Fixes every debug action whose label varies with local state, not only
    incidents.
  • [NO] in the incident list means "would not fire naturally", not "cannot be
    forced" — the action checks TargetAllowed but never CanFireNow. That is
    unchanged here, and is why the GiveQuest case above executes at all.
  • Two follow-ups are worth doing separately: logging an error when a debug node
    path cannot be resolved (that silent null is what made this expensive to
    find), and keeping path resolution out of the simulation's random stream.

A debug action replayed on another client re-derives whatever it needs from
local state. DebugSync already reproduces the cursor and the selected object,
but not which view the player had open -- and vanilla's incident action picks
its entire target from exactly that:

    WorldSelected ? Find.WorldSelector.SingleSelectedObject : Find.CurrentMap

So a host on the planet with a caravan selected fires at the caravan, while a
client looking at a colony fires at the map. The node's label is built from the
target's name, so the two clients also disagree about the label and
RecreateGraphAndGetNode finds nothing -- the command is accepted, acknowledged,
and silently never executed there. A synchronized command that runs on one
client and not the other is a desync, and this one leaves no trace where it
happens.

Carry the flag in the debug command and override the getter during replay,
following the MouseCellPatch and MouseTilePatch pattern already used for the
cursor. Read it before the node graph is rebuilt, since labels depend on it,
and clear it in the same finally block as the others so it cannot leak into
normal play.

The debug command's wire format gains one byte; host and clients need matching
builds, which this mod already requires.

@notfood notfood left a comment

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.

I don't buy that we can't read the state of the scene and needs a patch to WorldRendererUtility. Double check that.

public static class WorldSelectedPatch
{
/// <summary>Non-null only while a debug command is being replayed.</summary>
public static bool? result;

@notfood notfood Aug 5, 2026

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.

Something is wrong here, it's never assigned. Later it asks for .HasValue but it was never set.

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.

You are right that this file alone does not show it. The field does get set, but in the other file.

It is set here, in DebugSync.HandleCmd:

WorldSelectedPatch.result = data.ReadBool();

It is read from the network at that spot on purpose. The node graph gets rebuilt a few lines
later, and node labels can depend on the view. So the value has to be in place first.

It is set back to null in the finally block, next to the two cursor overrides:

MouseCellPatch.result = null;
MouseTilePatch.result = null;
WorldSelectedPatch.result = null;

This is the same pattern as the two patches right above it in Patches.cs. All three are set
and cleared in the same places:

Field Declared Set Cleared
MouseCellPatch.result Patches.cs#L172 DebugSync.cs#L31 #L122
MouseTilePatch.result Patches.cs#L184 DebugSync.cs#L33 #L123
WorldSelectedPatch.result Patches.cs#L209 DebugSync.cs#L55 #L124

Still, you had to open another file to answer this. I can update it if you want to say where the value is set and cleared, so the class makes sense on its own.

@M-r-A

M-r-A commented Aug 5, 2026

Copy link
Copy Markdown
Author

I don't buy that we can't read the state of the scene and needs a patch to WorldRendererUtility. Double check that.

My description was not clear, so let me explain what the patch is really for.

We can read the state, and we do. The sending client reads it with no patch at all:

writer.WriteBool(WorldRendererUtility.WorldSelected);

Reading was never the problem. The patch is for the other side, when the command is replayed
on a different client. There, reading the value gives that client's own view. That is the bug.
The host is on the planet and the client is on a colony, so they read different values and pick
different targets.

Now, the other option: set the real state instead of patching the getter. I checked that too.
WorldSelected is not stored anywhere. It is calculated (RimWorld.Planet.WorldRendererUtility, 1.6, decompiled):

public static bool WorldSelected => CurrentWorldRenderMode == WorldRenderMode.Planet;

public static WorldRenderMode CurrentWorldRenderMode
{
    get
    {
        ...
        if (Current.ProgramState == ProgramState.Playing && Find.CurrentMap == null)
            return WorldRenderMode.Planet;
        if (Find.World.renderer.wantedMode == WorldRenderMode.Planet)
            return WorldRenderMode.Planet;
        ...
    }
}

It comes from two things on the local client: Find.World.renderer.wantedMode and
Find.CurrentMap. So setting the real state means one of these:

  • Set wantedMode = Planet. This actually moves the other player's camera to the planet in the
    middle of a command. WorldRenderer also uses this field while drawing, and it can start
    regenerating world layers. That seemed worse than patching a getter.
  • Or set Find.CurrentMap to null. That is much riskier, since a lot of code reads it.

The patch changes only the answer, only while one command is replayed, and clears right after
in the same finally block as the cursor overrides. It is the same idea as MouseCellPatch:

[HarmonyPatch(typeof(UI), nameof(UI.MouseCell))]
public static class MouseCellPatch
{
public static IntVec3? result;
static void Postfix(ref IntVec3 __result)
{
if (result.HasValue)
__result = result.Value;
}
}

To replay where the mouse was, we override UI.MouseCell. We do not move the player's real
mouse.

I can add this reasoning to the comment on the class if you want. Right now it says what the patch does
but not why it is an override instead of setting the state. That is what made the question come
up.

If there is a way to do this that I missed, and it does not need a patch or move anything the
player sees, I am happy to use it.

@M-r-A
M-r-A requested a review from notfood August 5, 2026 01:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants