From f977744472c4c68c26d02c7fad73a0f066e6e2e4 Mon Sep 17 00:00:00 2001
From: Thaipho <72847365+Thaipho@users.noreply.github.com>
Date: Sat, 5 Sep 2026 00:10:30 -0500
Subject: [PATCH] Add RimThunder Core compat, fix 4 Vehicle Framework
desync/hang bugs
## RimThunder Core (new)
No existing compat for rimthunder.core anywhere in the repo. Adds:
- Isolates two unprotected ambient Rand sites in the projectile-interceptor
system (CompAbilityEffect_ActiveProtectionSystem.CompTick,
VehicleCompProjectileInterceptor.PostPostMake/PostExposeData)
- Syncs RimThunder's own deploy-toggle comp (Motorization.CompDeployable),
separate from Vehicle Framework's own CompVehicleTurrets deploy below
## Vehicle Framework fixes
Two are unambiguous - zero existing coverage, and RefuelHalfway's own
comment already flags the gap without ever closing it:
- CompFueledTravel.RefuelHalfway (dev gizmo): calls unsynced
ConsumeFuel(float.MaxValue) then synced, ADDITIVE Refuel(cap/2) - the
issuing peer ends at cap/2, every other peer adds cap/2 onto its own
untouched fuel value. Real fuel-value desync.
- SyncVehicleArrivalAction: rewritten from a type-name +
Activator.CreateInstance reconstruction (which only carried the
vehicle's thingIDNumber, itself unused on read) to real exposable
serialization, so ArrivalAction_LoadMap.arrivalModeDef,
ArrivalAction_LandInMap.mapParent and
AerialVehicleArrivalAction_StrafeMap.parent survive the trip.
Confirmed live: with the old reconstruction, ArrivalAction_LoadMap.
Arrived NREs on the null arrivalModeDef right after its long-event
lambda finishes generating the map. Vanilla's LongEventHandler only
sends MP's freeze-Unfreeze signal on that lambda's success path, so
the exception leaves everyone stuck on "Waiting for other players"
until someone quits (reported on Discord, laoune/Basilic, 2026-08-26:
MP + Vehicle Framework + a RimThunder helicopter to another tile).
Two more are real per the currently Workshop-live Vehicle Framework build
(3014915404) - confirmed via live 2-instance desync traces and by
decompiling that exact installed DLL - but I want to flag a real
uncertainty: several nearby comments in this file already say things
like "CompGetGizmosExtra has no lambdas now" / "MultiplePawnFloatMenuOptions
now uses OrderPawns method reference, no lambda to sync", and this file's
Vehicle Framework section hasn't been touched since 2026-03-18. That's
consistent with either the reference build this file was written against
having since changed (lambda ordinals shift silently between Vehicle
Framework builds - CompGetGizmosExtra's own comment shows this has
happened here before), or with something being version-specific enough
that it doesn't reproduce for you. Both registrations are defensive
(try/catch or a documented no-op-on-mismatch expectation), so a wrong
ordinal on a different build should fail safe rather than break anything
functional:
- CompVehicleTurrets deploy toggle: the "cached field (deployToggle),
no lambda to register" comment is about CompGetGizmosExtra
specifically - the toggleAction closure that flips Deployed still
exists, compiled inside RecacheGizmos (where deployToggle is built)
instead, and was never registered anywhere. Only the clicking peer's
vehicle ever deployed; Deployed drives CanMove/TurretsAligned/
DeploymentSatisfied and (with RimThunder) LaunchRestriction_WingDeployed.
- VehiclePawn.GetGizmos: 9 more state-mutating dev-only action lambdas
(Teleport/DestroyComponent/DamageComponent/ExplodeComponent/
HealAllComponents/GiveRandomPawnMentalState/DownRandomPawn/
KillRandomPawn/ToggleLoitering) were unregistered - instant desync in
dev mode if any is clicked.
- AerialVehicleAbandonOrBanishHelper.TryAbandonOrBanishViaInterface
(Thing, AerialVehicleInFlight): registered ordinal 0, which resolves
to the bool(Pawn) LINQ predicate, not the void confirm action
(ordinal 1) that actually removes the abandoned item from its owner's
inventory and destroys it. The sibling (TransferableImmutable, ...)
overload numbers these the opposite way, which is what made this easy
to miss. The pawn-banish branch is already redirected by
ReplaceVanillaBanishDialog/SyncedBanishPawn above, so this only
affects the plain-item abandon path.
Both projects (Source/Multiplayer_Compat.csproj and
Source_Referenced/Multiplayer_Compat_Referenced.csproj) build clean.
---
Source/Mods/RimThunderCore.cs | 67 ++++++++++++++
Source_Referenced/VehicleFramework.cs | 127 +++++++++++++++++++++-----
2 files changed, 170 insertions(+), 24 deletions(-)
create mode 100644 Source/Mods/RimThunderCore.cs
diff --git a/Source/Mods/RimThunderCore.cs b/Source/Mods/RimThunderCore.cs
new file mode 100644
index 00000000..0a774a16
--- /dev/null
+++ b/Source/Mods/RimThunderCore.cs
@@ -0,0 +1,67 @@
+using HarmonyLib;
+using Verse;
+
+namespace Multiplayer.Compat
+{
+ /// RimThunder - Core by RimThunder Teams
+ ///
+ [MpCompatFor("rimthunder.core")]
+ public class RimThunderCore
+ {
+ public RimThunderCore(ModContentPack mod)
+ {
+ // Of the 7 RimThunder mods, only Core ships compiled code for RimWorld 1.6
+ // (Motorization.dll, GuidedMissile.dll) - the 6 addon packs (Breakthrough, Desert
+ // Sabre, Flying Chariot, Liberty of Delivery, Red Dragon, Roaring Tiger) are pure
+ // content packs with no 1.6 assembly, nothing to patch for those.
+
+ // Motorization.CompAbilityEffect_ActiveProtectionSystem.CompTick rolls
+ // Rand.Range(0f, 1f) against Props.chanceToFail every tick the ability is active, to
+ // decide whether an incoming projectile gets intercepted - a gameplay-affecting roll
+ // (DoIntercept, called from within the same method, can spawn a Thing via
+ // GenSpawn.Spawn on a partial chance), with no Rand.PushState anywhere in the class.
+ PatchIsolated("Motorization.CompAbilityEffect_ActiveProtectionSystem", "CompTick");
+ // VehicleCompProjectileInterceptor.PostPostMake/PostExposeData (PostLoadInit branch)
+ // both seed nextChargeTick via Find.TickManager.TicksGame + Rand.Range(0,
+ // Props.chargeIntervalTicks) unprotected - fires whenever an interceptor-equipped
+ // vehicle is generated or loaded (trade caravan, raid spawn, map gen, save load).
+ PatchIsolated("Motorization.VehicleCompProjectileInterceptor", "PostPostMake");
+ PatchIsolated("Motorization.VehicleCompProjectileInterceptor", "PostExposeData");
+
+ // RimThunder ships its OWN deploy comp, Motorization.CompDeployable, separate from
+ // Vehicle Framework's CompVehicleTurrets deploy: its CompGetGizmosExtra builds a
+ // Command_Toggle whose toggleAction starts the DeployVehicle job and sets
+ // deployTicks. Unsynced: only the clicking peer's vehicle deployed, and Deployed
+ // drives movementStatus (mobileWhileDeployed) plus the Deployed/Undeployed vehicle
+ // events every other peer then never saw.
+ var deployable = AccessTools.TypeByName("Motorization.CompDeployable");
+ if (deployable != null)
+ MpCompat.RegisterLambdaMethod(deployable, "CompGetGizmosExtra", MethodType.Normal, 0);
+ else
+ Log.Warning("Multiplayer Compat :: RimThunder Core: could not find Motorization.CompDeployable - its deploy toggle will not be synced.");
+ }
+
+ private static void PatchIsolated(string typeName, string methodName)
+ {
+ var type = AccessTools.TypeByName(typeName);
+ var method = type != null ? AccessTools.DeclaredMethod(type, methodName) : null;
+ if (method != null)
+ {
+ MpCompat.harmony.Patch(method,
+ prefix: new HarmonyMethod(typeof(RimThunderCore), nameof(PreIsolateRand)),
+ finalizer: new HarmonyMethod(typeof(RimThunderCore), nameof(PostIsolateRand)));
+ }
+ else
+ {
+ Log.Warning($"Multiplayer Compat :: RimThunder Core: could not find {typeName}:{methodName} - mod may have updated, this fix is now stale for it.");
+ }
+ }
+
+ // Isolate rather than reseed: neither roll needs to reproduce a particular result (unlike
+ // e.g. a GenStep, which must reproduce the same map for a preview to match), it just must
+ // not leak forward into the shared stream. A finalizer (not a postfix) so PopState still
+ // runs if the patched method throws partway through.
+ private static void PreIsolateRand() => Rand.PushState();
+ private static void PostIsolateRand() => Rand.PopState();
+ }
+}
diff --git a/Source_Referenced/VehicleFramework.cs b/Source_Referenced/VehicleFramework.cs
index 639ddfea..b8e0c81a 100644
--- a/Source_Referenced/VehicleFramework.cs
+++ b/Source_Referenced/VehicleFramework.cs
@@ -139,6 +139,17 @@ static ISyncMethod TrySyncDeclaredMethod(Type targetType, string targetMethodNam
MpMethodUtil.GetLambda(typeof(VehiclePawn), nameof(VehiclePawn.GetGizmos), lambdaOrdinal: 13),
prefix: new HarmonyMethod(typeof(VehicleFramework), nameof(PreDisembarkSinglePawn)));
MP.RegisterSyncMethod(typeof(VehicleFramework), nameof(SyncedDisembarkPawn));
+ // (Dev) 9 more state-mutating GetGizmos action lambdas, all behind
+ // DebugSettings.ShowDevGizmos and none registered anywhere: 2=Teleport,
+ // 17=DestroyComponent, 19=DamageComponent (also consumes Rand),
+ // 21=ExplodeComponent, 22=HealAllComponents, 24=GiveRandomPawnMentalState (Rand),
+ // 25=DownRandomPawn (Rand), 26=KillRandomPawn (Rand), 28=ToggleLoitering. Each runs
+ // on the clicking peer only if unregistered - an instant desync in dev mode.
+ // Verified against the currently Workshop-live build (3014915404); if a newer
+ // Vehicle Framework build renumbers GetGizmos (as it evidently has before - see the
+ // ordinal 0/1 note on CompVehicleTurrets.CompGetGizmosExtra above), this registration
+ // should throw/no-op harmlessly on the wrong lambdas rather than break anything.
+ MpCompat.RegisterLambdaDelegate(typeof(VehiclePawn), nameof(VehiclePawn.GetGizmos), 2, 17, 19, 21, 22, 24, 25, 26, 28).SetDebugOnly();
// Toggle drafted or (if moving) engage brakes.
MpCompat.RegisterLambdaMethod(typeof(VehicleIgnitionController), nameof(VehicleIgnitionController.GetGizmos), 1);
@@ -164,13 +175,29 @@ static ISyncMethod TrySyncDeclaredMethod(Type targetType, string targetMethodNam
prefix: new HarmonyMethod(typeof(VehicleFramework), nameof(PreToggleFuelSwitch)));
MP.RegisterSyncMethod(typeof(VehicleFramework), nameof(SyncedToggleFuelSwitch));
// (Dev) set fuel to 0 (0), set fuel to max (1), set fuel to 99.99% (2)
- // RefuelHalfway is a method reference (not a lambda), so doesn't consume an ordinal
+ // RefuelHalfway is a method reference (not a lambda), so doesn't consume an ordinal,
+ // but it does still need its own registration: it calls the unsynced
+ // ConsumeFuel(float.MaxValue) directly (runs immediately at UI time) and then the
+ // synced Refuel(FuelCapacity / 2). Refuel is additive (fuel = Clamp(fuel + amount,
+ // 0, cap)), so the issuing peer ends at cap/2 while every other peer adds cap/2 onto
+ // its own untouched fuel value - CompFueledTravel.fuel diverges whenever the tank
+ // wasn't already empty.
+ MP.RegisterSyncMethod(typeof(CompFueledTravel), nameof(CompFueledTravel.RefuelHalfway)).SetDebugOnly();
MpCompat.RegisterLambdaMethod(typeof(CompFueledTravel), nameof(CompFueledTravel.DevModeGizmos), 0, 1, 2).SetDebugOnly();
// (Dev) set fuel to 0/max
MpCompat.RegisterLambdaMethod(typeof(CompFueledTravel), nameof(CompFueledTravel.CompCaravanGizmos), 0, 1).SetDebugOnly();
MP.RegisterSyncMethod(typeof(CompVehicleTurrets), nameof(CompVehicleTurrets.SetQuotaLevel));
- // Deploy turret is now a cached field (deployToggle), no lambda to register
+ // Deploy turret is now a cached field (deployToggle), no lambda to register in
+ // CompGetGizmosExtra - but the toggleAction closure that flips it is still a lambda,
+ // just compiled inside RecacheGizmos (where deployToggle is built) instead. Verified
+ // against the currently Workshop-live build (3014915404): CompVehicleTurrets.
+ // RecacheGizmos's ordinal 0 is exactly that toggleAction (starts the DeployVehicle
+ // job, sets deployTicks). It was never registered anywhere, so only the clicking
+ // peer's vehicle ever deployed - Deployed then drives CanMove/TurretsAligned/
+ // DeploymentSatisfied and (with RimThunder installed) LaunchRestriction_WingDeployed,
+ // a straight sim-state divergence the moment anyone deploys or undeploys.
+ MpCompat.RegisterLambdaMethod(typeof(CompVehicleTurrets), nameof(CompVehicleTurrets.RecacheGizmos), 0);
// (Dev) full reload turret — only lambda in CompGetGizmosExtra now
MpCompat.RegisterLambdaDelegate(typeof(CompVehicleTurrets), nameof(CompVehicleTurrets.CompGetGizmosExtra), 0).SetDebugOnly();
@@ -246,6 +273,32 @@ static ISyncMethod TrySyncDeclaredMethod(Type targetType, string targetMethodNam
MpCompat.RegisterLambdaDelegate(typeof(VehiclePawn), nameof(VehiclePawn.GetFloatMenuOptions), 0);
// MultiplePawnFloatMenuOptions now uses OrderPawns method reference, no lambda to sync
// The boarding action is handled through the method reference directly.
+ //
+ // Verified against the currently Workshop-live build (3014915404): OrderPawns is a
+ // local function compiled inside MultiplePawnFloatMenuOptions (the "board vehicle for
+ // every selected pawn" multi-select action) and does need registering there - live
+ // 2-instance testing showed it running host-only (confirmed via a normalized diff of
+ // both peers' JIT'd-method lists), filing the seat assignment locally and shipping
+ // only the nested TryTakeOrderedJob call, so receivers ran the Board job with no
+ // assignment and it silently no-opped. If a newer Vehicle Framework build genuinely
+ // reworked this into a plain method reference with no local function, this
+ // registration should throw/no-op harmlessly rather than break anything.
+ //
+ // Also sync PromptToBoardVehicle itself: it does two things (GiveLoadJob, which adds
+ // an AssignedSeat to vehicle.boardingAssignments - plain local state - and
+ // TryTakeOrderedJob, which is already an MP sync method), so any OTHER caller (other
+ // mods, future Vehicle Framework paths) that reaches it outside the OrderPawns loop
+ // is covered too; nested inside an already-executing command it just runs.
+ MP.RegisterSyncMethod(typeof(VehiclePawn), nameof(VehiclePawn.PromptToBoardVehicle));
+ try
+ {
+ var orderPawns = MpMethodUtil.GetLocalFunc(typeof(VehiclePawn), nameof(VehiclePawn.MultiplePawnFloatMenuOptions), localFunc: "OrderPawns");
+ MP.RegisterSyncDelegate(typeof(VehiclePawn), orderPawns.DeclaringType!.Name, orderPawns.Name);
+ }
+ catch (Exception ex)
+ {
+ Log.Warning($"Multiplayer Compat :: Could not sync VehiclePawn.MultiplePawnFloatMenuOptions/OrderPawns (multi-select boarding will desync on other peers): {ex.Message}");
+ }
}
#endregion
@@ -384,12 +437,23 @@ static ISyncMethod TrySyncDeclaredMethod(Type targetType, string targetMethodNam
var typesTransferable = new[] { typeof(TransferableImmutable), typeof(AerialVehicleInFlight) };
// Abandon non-pawn Thing
+ //
+ // Verified against the currently Workshop-live build (3014915404): for THIS overload
+ // (Thing, AerialVehicleInFlight), ordinal 0 is the bool(Pawn) LINQ predicate used by
+ // GenCollection.Any(...) on the pawn-banish branch (redirected below anyway), not the
+ // void confirm action that actually removes the item from its owning pawn's inventory
+ // and destroys it - that's ordinal 1. The sibling (TransferableImmutable,
+ // AerialVehicleInFlight) overload below numbers its own two lambdas the other way
+ // (confirm action = 0 there), which is what made this easy to get wrong. Registering
+ // ordinal 0 here means the confirm action never runs synced - the item is removed and
+ // destroyed on the issuing peer only, diverging inventory contents / Thing existence
+ // the next time that item or its owner's inventory is touched.
method = MpMethodUtil.GetLambda(
typeof(AerialVehicleAbandonOrBanishHelper),
nameof(AerialVehicleAbandonOrBanishHelper.TryAbandonOrBanishViaInterface),
MethodType.Normal,
typesThing,
- 0);
+ 1);
MP.RegisterSyncDelegate(typeof(AerialVehicleAbandonOrBanishHelper), method.DeclaringType!.Name, method.Name);
// Abandon specific Pawn, replace the vanilla banish interaction with our synced one as
// syncing of the pawn fails here. All the other methods redirect pawn banishing here.
@@ -1064,42 +1128,57 @@ private static SmashTools.Targeting.TargetData ReconstructTarg
return targetData;
}
+ // Rewritten from a type-name + Activator.CreateInstance reconstruction that only carried the
+ // vehicle's thingIDNumber (unused on read - "Vehicle will be resolved when Launch executes")
+ // and dropped every other field: ArrivalAction_LoadMap.arrivalModeDef,
+ // ArrivalAction_LandInMap.mapParent, AerialVehicleArrivalAction_StrafeMap.parent. Verified
+ // against the currently Workshop-live build (3014915404): ArrivalAction_LoadMap.Arrived's
+ // long-event lambda generates the destination map and THEN calls
+ // arrivalModeDef.Worker.VehicleArrived(...) with a null arrivalModeDef -
+ // NullReferenceException inside a synced long event. Vanilla's LongEventHandler only sends
+ // MP's freeze-Unfreeze signal on the success path of UpdateCurrentSynchronousEvent /
+ // UpdateCurrentEnumeratorEvent, so an uncaught exception there leaves every player stuck on
+ // "Waiting for other players" until someone quits - reported live (Discord, laoune/Basilic,
+ // 2026-08-26) as: sending a helicopter to another tile shows "Generating map" on everyone,
+ // then a permanent freeze. Every VehicleArrivalAction is IExposable and already scribes
+ // exactly the fields each subclass needs, so this sends the real object through MP's
+ // exposable serialization (SyncType.expose) instead - Scribe_Deep records the concrete class,
+ // so the abstract base type works on both ends. The vehicle field is blanked for the write:
+ // the receiving side re-attaches it anyway (SyncedLaunch / SyncedCaravanLaunch /
+ // SyncedOrderFlyToTiles / PreMoveForwardFixArrivalVehicle below), and an in-flight vehicle is
+ // despawned so its cross-reference wouldn't resolve and would only log a warning.
+ private static readonly SyncType vehicleArrivalActionExposeType = new SyncType(typeof(VehicleArrivalAction)) { expose = true };
+
private static void SyncVehicleArrivalAction(SyncWorker sync, ref VehicleArrivalAction action)
{
if (sync.isWriting)
{
var isNull = action == null;
sync.Write(isNull);
- if (!isNull)
+ if (isNull)
+ return;
+
+ var vehicle = arrivalActionVehicleField(action);
+ arrivalActionVehicleField(action) = null;
+ try
{
- sync.Write(action.GetType().FullName);
- // Write vehicle thingIDNumber — can't use sync.Write because
- // the vehicle may be in a transitional state (inside skyfaller).
- // We'll resolve it from the comp's vehicle during Launch execution.
- var vehicle = arrivalActionVehicleField(action);
- sync.Write(vehicle?.thingIDNumber ?? -1);
+ sync.Write(action, vehicleArrivalActionExposeType);
+ }
+ finally
+ {
+ arrivalActionVehicleField(action) = vehicle;
}
}
else
{
var isNull = sync.Read();
if (isNull)
+ {
+ action = null;
return;
+ }
- var typeName = sync.Read();
- var vehicleId = sync.Read();
-
- if (string.IsNullOrEmpty(typeName))
- return;
-
- var type = AccessTools.TypeByName(typeName);
- if (type == null)
- return;
-
- action = (VehicleArrivalAction)Activator.CreateInstance(type);
- // Vehicle will be resolved when Launch executes — the CompVehicleLauncher
- // target is synced separately and has the correct vehicle reference.
- // Store the ID for now; the Launch method sets it via the comp's Vehicle.
+ action = sync.Read(vehicleArrivalActionExposeType);
}
}