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);
}
}