Stage 1: walk while healing legs, for SPT 4.0.13
Ground-up reimplementation of yox92/SmartAction targeting SPT 4.0.13 / EFT 0.16.9.0.40087. Only the walk-while-healing feature is implemented; see NOTES.md for status and the deferred sprint work. Design departures from upstream: - CanWalk is a guarded postfix, not a transpiler. Upstream rewrote the method body, which affects every MovementContext in the raid including bots, and is hard-incompatible with the three other mods already patching MovementContext in a typical load order (SAIN, ORBIT, maschine-DualSideDoorBreach). A postfix that only flips false -> true for IsYourPlayer is inert for other entities by construction. - The postfix re-tests ObstacleCollisionFacade_1.CanMove() before lifting. Upstream's transpiler dropped that check along with the HealingLegs check, silently disabling obstacle blocking. - Targets resolve by name via AccessTools.PropertyGetter + nameof rather than by GClass number, so they survive EFT version bumps. Verified in-game against 78 loaded plugins: both patches attach, the guard rejected 985 non-local MovementContext calls with zero leaks, and _player was never null across ~4000 guard calls. CanSprintPatch is parked in deferred/ rather than deleted: MovementContext.CanSprint does not gate sprinting (EnableSprint calls Physical.Sprint unconditionally), and forcing it true makes EnableSprint run ForceStopInteractions()/RemoveLeftHandItem() mid-heal. Details and the 3.11 -> 4.0.13 rename table are in NOTES.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
using System.Reflection;
|
||||
using EFT;
|
||||
using HarmonyLib;
|
||||
using SPT.Reflection.Patching;
|
||||
|
||||
namespace SmartAction.Patch;
|
||||
|
||||
/// <summary>
|
||||
/// Lets you walk while healing a leg.
|
||||
///
|
||||
/// Vanilla MovementContext.CanWalk (EFT 0.16.9.0.40087):
|
||||
/// if (PhysicalConditionIs(HealingLegs)) return false;
|
||||
/// if (!ObstacleCollisionFacade_1.CanMove()) return false;
|
||||
/// return true;
|
||||
///
|
||||
/// Upstream SmartAction replaced this body with a transpiler, which rewrote the method for
|
||||
/// every MovementContext in the raid — bots included — and dropped the obstacle check along
|
||||
/// with the HealingLegs check. A postfix that only ever flips false -> true is per-instance,
|
||||
/// composes with other movement mods, needs no IL matching, and keeps CanMove() intact.
|
||||
/// </summary>
|
||||
public class CanWalkPatch : ModulePatch
|
||||
{
|
||||
protected override MethodBase GetTargetMethod()
|
||||
{
|
||||
return AccessTools.PropertyGetter(typeof(MovementContext), nameof(MovementContext.CanWalk));
|
||||
}
|
||||
|
||||
[PatchPostfix]
|
||||
private static void Postfix(MovementContext __instance, ref bool __result)
|
||||
{
|
||||
// Never grant movement vanilla already allows, and never take it away.
|
||||
if (__result) return;
|
||||
|
||||
if (!MovementPatchUtil.IsLocalPlayer(__instance)) return;
|
||||
|
||||
// Only intervene when healing is the reason we were blocked.
|
||||
if (!__instance.PhysicalConditionIs(EPhysicalCondition.HealingLegs)) return;
|
||||
|
||||
// The other vanilla blocker still applies: if something is physically in the way,
|
||||
// leave the false alone. (Null facade -> treat as blocked.)
|
||||
if (__instance.ObstacleCollisionFacade_1?.CanMove() != true)
|
||||
{
|
||||
if (Diagnostics.Enabled)
|
||||
{
|
||||
Diagnostics.WalkHeldByObstacle++;
|
||||
Diagnostics.OnceWalkHeldByObstacle();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
__result = true;
|
||||
|
||||
if (Diagnostics.Enabled)
|
||||
{
|
||||
Diagnostics.WalkLifted++;
|
||||
Diagnostics.OnceWalkLifted();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace SmartAction.Patch;
|
||||
|
||||
/// <summary>
|
||||
/// Verification instrumentation for the movement patches.
|
||||
///
|
||||
/// These postfixes run on every movement query for every entity in the raid, so nothing here
|
||||
/// may allocate or log on the hot path. Counters are plain increments; the periodic flush is
|
||||
/// gated behind a call counter so Time.unscaledTime is read once per ~4096 calls rather than
|
||||
/// per call. Disable via the "Diagnostics" config entry once the design is trusted.
|
||||
/// </summary>
|
||||
internal static class Diagnostics
|
||||
{
|
||||
internal static bool Enabled;
|
||||
|
||||
private const int TickGate = 4096;
|
||||
private const float FlushIntervalSeconds = 30f;
|
||||
|
||||
// Guard outcomes — the multiplayer-safety evidence.
|
||||
internal static long LocalCalls;
|
||||
internal static long NonLocalCalls;
|
||||
internal static long NullPlayerCalls;
|
||||
|
||||
// Patch outcomes.
|
||||
internal static long WalkLifted;
|
||||
internal static long WalkHeldByObstacle;
|
||||
|
||||
private static int _tick;
|
||||
private static float _nextFlush;
|
||||
|
||||
// One-shot announcements, so the first occurrence of each interesting event is visible
|
||||
// immediately instead of waiting up to 30s for a flush.
|
||||
private static bool _saidWalkLift;
|
||||
private static bool _saidWalkObstacle;
|
||||
private static bool _saidNullPlayer;
|
||||
|
||||
internal static void OnceWalkLifted()
|
||||
{
|
||||
if (_saidWalkLift) return;
|
||||
_saidWalkLift = true;
|
||||
Plugin.Log.LogInfo("[diag] CanWalk LIFTED (HealingLegs) — first occurrence");
|
||||
}
|
||||
|
||||
internal static void OnceWalkHeldByObstacle()
|
||||
{
|
||||
if (_saidWalkObstacle) return;
|
||||
_saidWalkObstacle = true;
|
||||
Plugin.Log.LogInfo("[diag] CanWalk HELD by obstacle check — first occurrence "
|
||||
+ "(this is the CanMove() guard upstream's transpiler dropped)");
|
||||
}
|
||||
|
||||
internal static void OnceNullPlayer()
|
||||
{
|
||||
if (_saidNullPlayer) return;
|
||||
_saidNullPlayer = true;
|
||||
Plugin.Log.LogWarning("[diag] MovementContext._player was NULL — guard handled it without throwing. "
|
||||
+ "This is the predicted NRE case; it is being absorbed correctly.");
|
||||
}
|
||||
|
||||
/// <summary>Called from the guard. Cheap on all but 1-in-4096 calls.</summary>
|
||||
internal static void Tick()
|
||||
{
|
||||
if (++_tick < TickGate) return;
|
||||
_tick = 0;
|
||||
|
||||
var now = Time.unscaledTime;
|
||||
if (now < _nextFlush) return;
|
||||
_nextFlush = now + FlushIntervalSeconds;
|
||||
|
||||
Flush();
|
||||
}
|
||||
|
||||
internal static void Flush()
|
||||
{
|
||||
Plugin.Log.LogInfo(
|
||||
$"[diag] guard: local={LocalCalls} nonLocal={NonLocalCalls} nullPlayer={NullPlayerCalls} | "
|
||||
+ $"walk: lifted={WalkLifted} heldByObstacle={WalkHeldByObstacle}");
|
||||
|
||||
if (NonLocalCalls == 0)
|
||||
{
|
||||
Plugin.Log.LogWarning("[diag] nonLocal=0 — no bot/remote MovementContext has been seen yet. "
|
||||
+ "Either you are not in a raid with AI, or the postfixes are not firing "
|
||||
+ "for other entities. The guard is unproven until this is non-zero.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using System.Reflection;
|
||||
using EFT;
|
||||
using HarmonyLib;
|
||||
|
||||
namespace SmartAction.Patch;
|
||||
|
||||
internal static class MovementPatchUtil
|
||||
{
|
||||
// MovementContext._player is protected, so it needs reflection. Resolved once at
|
||||
// type-init rather than per call — these postfixes run on every movement query.
|
||||
private static readonly FieldInfo PlayerField = AccessTools.Field(typeof(MovementContext), "_player");
|
||||
|
||||
/// <summary>
|
||||
/// True only for the player this client controls.
|
||||
///
|
||||
/// Every bot and, under Fika, every remote player owns its own MovementContext, and these
|
||||
/// postfixes fire for all of them. Gating on IsYourPlayer is what keeps the patches inert
|
||||
/// for everyone else — it is the entire multiplayer-safety story for Stage 1.
|
||||
/// </summary>
|
||||
internal static bool IsLocalPlayer(MovementContext context)
|
||||
{
|
||||
var value = PlayerField?.GetValue(context);
|
||||
|
||||
// Fast path when instrumentation is off: no counters, no branches beyond the check.
|
||||
if (!Diagnostics.Enabled)
|
||||
{
|
||||
return value is Player { IsYourPlayer: true };
|
||||
}
|
||||
|
||||
if (value is not Player player)
|
||||
{
|
||||
// Predicted failure mode: a MovementContext whose _player is not yet assigned
|
||||
// (bot mid-spawn). The pattern match absorbs it rather than throwing an NRE.
|
||||
Diagnostics.NullPlayerCalls++;
|
||||
Diagnostics.OnceNullPlayer();
|
||||
Diagnostics.Tick();
|
||||
return false;
|
||||
}
|
||||
|
||||
var isLocal = player.IsYourPlayer;
|
||||
if (isLocal) Diagnostics.LocalCalls++;
|
||||
else Diagnostics.NonLocalCalls++;
|
||||
|
||||
Diagnostics.Tick();
|
||||
return isLocal;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user