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:
funman300
2026-07-28 18:45:42 -07:00
commit 8de369869f
9 changed files with 515 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
bin/
obj/
*.user
+88
View File
@@ -0,0 +1,88 @@
# SmartAction — SPT 4.0.13 port notes
Target: SPT 4.0.13 / EFT 0.16.9.0.40087 / BepInEx 5.4.23.2 / netstandard2.1.
Reference assemblies live in the game install; see `SmartAction.csproj` (`SptDir` property).
Build: `dotnet build -c Release -p:DeployToGame=true`
---
## Stage 1 — status
| Feature | Status |
|---|---|
| Walk while healing legs | **Working** — confirmed in-game and in logs |
| Guard rejects bots / remote players | **Proven** — `nonLocal=985` guard rejections, zero leaks |
| Obstacle check preserved | Structurally guaranteed; never observed firing (`heldByObstacle=0`) |
| Sprint while using meds | **Deferred** — see below |
Verified 2026-07-28 with 78 other plugins loaded, including SAIN, ORBIT and
maschine-DualSideDoorBreach, all three of which also patch `MovementContext`.
---
## Why CanSprintPatch is deferred
`deferred/CanSprintPatch.cs.txt` compiles and its logic is correct, but it does not
achieve the feature, and it has a side effect. **Do not simply re-enable it.**
`MovementContext.CanSprint` does not gate sprinting. The consumer is:
```csharp
public void EnableSprint(bool enable)
{
_player.Physical.Sprint(enable); // actual sprint request — never checks CanSprint
if (enable && CanSprint)
{
PlayerAnimator.AnimatedInteractions.ForceStopInteractions();
_player.RemoveLeftHandItem();
}
if (!enable) RaiseChangeSpeedEvent();
}
```
Two consequences:
1. Forcing `CanSprint = true` does not let you sprint. `Physical.Sprint(bool)` sets a flag
(`BasePhysicalClass.Sprint` → `Bool_2`); the real gate is in the derived `Physical` class
or the sprint input path. **Not yet identified — this is the open question for Stage 2/3.**
2. In vanilla, `CanSprint` is `false` mid-heal so the `ForceStopInteractions()` /
`RemoveLeftHandItem()` block never runs. Patching it to `true` makes that block run
during a heal, which is a behavior change we did not intend.
Upstream's `Patch/PatchSprint.cs` uses the *same* condition chain as ours (a prefix rather
than a postfix), so upstream did not solve this via `CanSprint` either. Sprint-while-consuming
most likely depends on the `MedController` / `method_8` patches that Stage 1 deliberately
skipped — fold it in there.
**Lesson:** patching a *property* means inheriting every consumer's interpretation of it.
`CanSprint` answers "may the player sprint" for one caller and "should we cancel what's in
their hands" for another.
---
## Design decisions worth keeping
- **Postfix, not transpiler/prefix.** Upstream rewrote `CanWalk`'s IL globally and used a
`false`-returning prefix on `CanSprint`. Both affect every entity in the raid. A postfix
that only flips `false -> true`, guarded on `IsYourPlayer`, is inert for bots by
construction — and composes with SAIN/ORBIT, which are already in `MovementContext`.
- **Upstream's `CanWalk` transpiler dropped the `ObstacleCollisionFacade_1.CanMove()` check**
along with the `HealingLegs` check. `CanWalkPatch` re-tests it before lifting.
- **Resolve by name, never by GClass number.** `AccessTools.PropertyGetter(typeof(T),
nameof(...))` survives EFT updates. Confirmed working at runtime, not just at compile time.
## Rename table (3.11 → 4.0.13)
| 3.11 | 4.0.13 |
|---|---|
| `Player.MedsController.Class1172` | `Player.MedsController.ObservedMedsControllerClass` |
| `ActiveHealthController.GClass2813` | `ActiveHealthController.GClass3008` |
| `GClass2823_0` | `GClass3019_0` |
| `float_12` | `Float_12` |
| `medsController_0` | `MedsController_0` |
| `activeHealthController_0` | `ActiveHealthController_0` |
| `firearmsAnimator_0` | unchanged — still lowercase |
Trap: the property is named `ObservedMedsControllerClass` but its getter MethodDef is still
`get_Class1291_0`. Use `AccessTools.PropertyGetter`, never the `get_` string.
+59
View File
@@ -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();
}
}
}
+87
View File
@@ -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.");
}
}
}
+47
View File
@@ -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;
}
}
+42
View File
@@ -0,0 +1,42 @@
using BepInEx;
using BepInEx.Configuration;
using BepInEx.Logging;
using SmartAction.Patch;
namespace SmartAction;
[BepInPlugin(PluginGuid, PluginName, PluginVersion)]
[BepInDependency("com.SPT.core", "4.0.0")]
public class Plugin : BaseUnityPlugin
{
public const string PluginGuid = "com.yox92.smartaction";
public const string PluginName = "SmartAction";
public const string PluginVersion = "2.0.0";
internal static ManualLogSource Log { get; private set; }
private ConfigEntry<bool> _diagnostics;
private void Awake()
{
Log = Logger;
_diagnostics = Config.Bind(
"Debug",
"Diagnostics",
true,
"Log patch and guard activity. Counters are cheap, but this writes a summary line "
+ "every 30s while in raid. Turn off once the behaviour is trusted.");
Diagnostics.Enabled = _diagnostics.Value;
_diagnostics.SettingChanged += (_, _) => Diagnostics.Enabled = _diagnostics.Value;
// Stage 1 is walk-only. CanSprintPatch is deferred to Stage 2/3 — see NOTES.md:
// MovementContext.CanSprint does not gate sprinting, and patching it caused
// EnableSprint() to call ForceStopInteractions()/RemoveLeftHandItem() mid-heal.
new CanWalkPatch().Enable();
Log.LogInfo($"{PluginName} {PluginVersion} loaded (walk-while-healing active, "
+ $"diagnostics {(Diagnostics.Enabled ? "ON" : "off")}).");
}
}
+66
View File
@@ -0,0 +1,66 @@
# SmartAction — SPT 4.0.x
Lets you **walk while healing a leg** in SPT.
Targets **SPT 4.0.13 / EFT 0.16.9.0.40087**, BepInEx 5, `netstandard2.1`.
---
## Attribution
This is a reimplementation of **[yox92/SmartAction](https://github.com/yox92/SmartAction)**,
which targets an earlier SPT version. The feature idea, the original research into which
EFT internals to touch, and the name are yox92's work.
**Upstream ships no LICENSE file.** If you intend to redistribute this or build on it,
ask yox92 for an explicit license first — absence of a license means all rights reserved,
and that applies to derivatives too.
This is not a drop-in port. The patches were rewritten rather than translated; see
[NOTES.md](NOTES.md) for what changed and why.
---
## Status
| Feature | Status |
|---|---|
| Walk while healing legs | Working |
| Sprint while using meds | **Not implemented** — see NOTES.md |
| Adaptive heal speed | Not implemented |
| Quick-cancel surgery | Not implemented |
Only the first feature exists today. The rest of upstream's feature set is unported.
## Install
Drop `SmartAction.dll` into `BepInEx/plugins/SmartAction/`.
## Build
Requires the .NET SDK. Reference assemblies are read from a local SPT install:
```bash
dotnet build -c Release -p:SptDir=/path/to/SPT
```
Add `-p:DeployToGame=true` to copy the result straight into `BepInEx/plugins/SmartAction/`.
No BSG or SPT assemblies are redistributed here — the build reads them from your own install.
## Multiplayer / Fika
The patch is guarded on `IsYourPlayer` and only ever grants movement, never removes it, so
it is inert for bots and remote players by construction. Verified in a live raid: 985 guard
rejections of non-local `MovementContext` instances with zero leaks, alongside SAIN, ORBIT
and maschine-DualSideDoorBreach, all of which also patch `MovementContext`.
No Fika dependency, no custom packets. It does not touch authoritative health state.
## Config
`BepInEx/config/com.yox92.smartaction.cfg`
| Setting | Default | Purpose |
|---|---|---|
| `Diagnostics` | `true` | Logs patch/guard activity and a counter summary every 30s. Turn off once you trust it. |
+41
View File
@@ -0,0 +1,41 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.1</TargetFramework>
<LangVersion>latest</LangVersion>
<Nullable>disable</Nullable>
<AssemblyName>SmartAction</AssemblyName>
<RootNamespace>SmartAction</RootNamespace>
<Version>2.0.0</Version>
<RestoreSources>https://api.nuget.org/v3/index.json</RestoreSources>
<!-- BSG/Unity assemblies are resolved straight out of the game install.
Override on the command line for a different install:
dotnet build -p:SptDir=/path/to/SPT -->
<SptDir Condition="'$(SptDir)' == ''">/mnt/games/SPT</SptDir>
<ManagedDir>$(SptDir)/EscapeFromTarkov_Data/Managed</ManagedDir>
</PropertyGroup>
<ItemGroup>
<!-- Game -->
<Reference Include="Assembly-CSharp" HintPath="$(ManagedDir)/Assembly-CSharp.dll" Private="false" />
<Reference Include="Comfort" HintPath="$(ManagedDir)/Comfort.dll" Private="false" />
<Reference Include="UnityEngine" HintPath="$(ManagedDir)/UnityEngine.dll" Private="false" />
<Reference Include="UnityEngine.CoreModule" HintPath="$(ManagedDir)/UnityEngine.CoreModule.dll" Private="false" />
<!-- BepInEx + Harmony -->
<Reference Include="BepInEx" HintPath="$(SptDir)/BepInEx/core/BepInEx.dll" Private="false" />
<Reference Include="0Harmony" HintPath="$(SptDir)/BepInEx/core/0Harmony.dll" Private="false" />
<!-- SPT -->
<Reference Include="spt-reflection" HintPath="$(SptDir)/BepInEx/plugins/spt/spt-reflection.dll" Private="false" />
</ItemGroup>
<!-- Opt-in deploy: dotnet build -p:DeployToGame=true -->
<Target Name="DeployToGame" AfterTargets="Build" Condition="'$(DeployToGame)' == 'true'">
<PropertyGroup>
<PluginDir>$(SptDir)/BepInEx/plugins/SmartAction</PluginDir>
</PropertyGroup>
<MakeDir Directories="$(PluginDir)" />
<Copy SourceFiles="$(TargetPath)" DestinationFolder="$(PluginDir)" />
<Message Importance="high" Text="Deployed $(TargetFileName) -> $(PluginDir)" />
</Target>
</Project>
+82
View File
@@ -0,0 +1,82 @@
using System.Reflection;
using EFT;
using HarmonyLib;
using SPT.Reflection.Patching;
namespace SmartAction.Patch;
/// <summary>
/// Lets you sprint while using meds / healing legs.
///
/// Vanilla MovementContext.CanSprint (EFT 0.16.9.0.40087):
/// if (PhysicalConditionIs(SprintDisabled)) return false;
/// if (PhysicalConditionIs(UsingMeds)) return false; // lifted here
/// if (PhysicalConditionIs(HealingLegs)) return false; // lifted here
/// if (PhysicalConditionIs(OnPainkillers)) return true;
/// if (PhysicalConditionIs(RightLegDamaged)) return false;
/// if (PhysicalConditionIs(LeftLegDamaged)) return false;
/// return true;
///
/// Upstream SmartAction used a prefix that returned false outright, suppressing the original
/// getter for every MovementContext in the raid. That is hard-incompatible with any other mod
/// touching sprint or stamina. This lifts exactly the two med-related blocks and replays the
/// rest of vanilla's chain, so leg damage and SprintDisabled keep their normal meaning.
/// </summary>
public class CanSprintPatch : ModulePatch
{
protected override MethodBase GetTargetMethod()
{
return AccessTools.PropertyGetter(typeof(MovementContext), nameof(MovementContext.CanSprint));
}
[PatchPostfix]
private static void Postfix(MovementContext __instance, ref bool __result)
{
if (__result) return;
if (!MovementPatchUtil.IsLocalPlayer(__instance)) return;
// Not ours to override — this is a deliberate game/quest/effect lockout.
if (__instance.PhysicalConditionIs(EPhysicalCondition.SprintDisabled))
{
if (Diagnostics.Enabled) Diagnostics.SprintHeldBySprintDisabled++;
return;
}
// Only intervene when meds or leg-healing is what blocked us.
var blockedByMeds = __instance.PhysicalConditionIs(EPhysicalCondition.UsingMeds)
|| __instance.PhysicalConditionIs(EPhysicalCondition.HealingLegs);
if (!blockedByMeds) return;
// Replay the remainder of vanilla's chain so a broken leg still stops you.
if (__instance.PhysicalConditionIs(EPhysicalCondition.OnPainkillers))
{
__result = true;
if (Diagnostics.Enabled)
{
Diagnostics.SprintLifted++;
Diagnostics.OnceSprintLifted();
}
return;
}
if (__instance.PhysicalConditionIs(EPhysicalCondition.RightLegDamaged)
|| __instance.PhysicalConditionIs(EPhysicalCondition.LeftLegDamaged))
{
if (Diagnostics.Enabled)
{
Diagnostics.SprintHeldByLeg++;
Diagnostics.OnceSprintHeldByLeg();
}
return;
}
__result = true;
if (Diagnostics.Enabled)
{
Diagnostics.SprintLifted++;
Diagnostics.OnceSprintLifted();
}
}
}