commit 8de369869f4335ef57ffcc4bbb95dedff34a8007 Author: funman300 Date: Tue Jul 28 18:45:42 2026 -0700 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ac8d21b --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +bin/ +obj/ +*.user diff --git a/NOTES.md b/NOTES.md new file mode 100644 index 0000000..fbaaf14 --- /dev/null +++ b/NOTES.md @@ -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. diff --git a/Patch/CanWalkPatch.cs b/Patch/CanWalkPatch.cs new file mode 100644 index 0000000..4b543e7 --- /dev/null +++ b/Patch/CanWalkPatch.cs @@ -0,0 +1,59 @@ +using System.Reflection; +using EFT; +using HarmonyLib; +using SPT.Reflection.Patching; + +namespace SmartAction.Patch; + +/// +/// 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. +/// +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(); + } + } +} diff --git a/Patch/Diagnostics.cs b/Patch/Diagnostics.cs new file mode 100644 index 0000000..059cc64 --- /dev/null +++ b/Patch/Diagnostics.cs @@ -0,0 +1,87 @@ +using UnityEngine; + +namespace SmartAction.Patch; + +/// +/// 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. +/// +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."); + } + + /// Called from the guard. Cheap on all but 1-in-4096 calls. + 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."); + } + } +} diff --git a/Patch/MovementPatchUtil.cs b/Patch/MovementPatchUtil.cs new file mode 100644 index 0000000..df6856b --- /dev/null +++ b/Patch/MovementPatchUtil.cs @@ -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"); + + /// + /// 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. + /// + 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; + } +} diff --git a/Plugin.cs b/Plugin.cs new file mode 100644 index 0000000..960eb4d --- /dev/null +++ b/Plugin.cs @@ -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 _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")})."); + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..5ae7ae5 --- /dev/null +++ b/README.md @@ -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. | diff --git a/SmartAction.csproj b/SmartAction.csproj new file mode 100644 index 0000000..13e7269 --- /dev/null +++ b/SmartAction.csproj @@ -0,0 +1,41 @@ + + + + netstandard2.1 + latest + disable + SmartAction + SmartAction + 2.0.0 + https://api.nuget.org/v3/index.json + + /mnt/games/SPT + $(SptDir)/EscapeFromTarkov_Data/Managed + + + + + + + + + + + + + + + + + + + $(SptDir)/BepInEx/plugins/SmartAction + + + + + + + diff --git a/deferred/CanSprintPatch.cs.txt b/deferred/CanSprintPatch.cs.txt new file mode 100644 index 0000000..2892e92 --- /dev/null +++ b/deferred/CanSprintPatch.cs.txt @@ -0,0 +1,82 @@ +using System.Reflection; +using EFT; +using HarmonyLib; +using SPT.Reflection.Patching; + +namespace SmartAction.Patch; + +/// +/// 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. +/// +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(); + } + } +}