diff --git a/README.md b/README.md
index 6289c97..a07488f 100644
--- a/README.md
+++ b/README.md
@@ -9,6 +9,8 @@ Currently, this mod includes the following life hacks:
- **VAB Mission Tracker** by [munix](https://github.com/jan-bures) - Adds the Mission Tracker button to the VAB app tray.
- **Disable Contrails** by [munix](https://github.com/jan-bures) - Disables contrails and wingtip vortices. Disabled by
default, see the [Configuration](#Configuration) section for details.
+- **Better Experiments** by [dmarcuse](https://github.com/dmarcuse) - Automatically resumes paused experiments when
+ re-entering the correct region, and ignores animation state.
## Installation
### Recommended
1. Use [CKAN](https://github.com/KSP-CKAN/CKAN/releases/latest) to download and install Kerbal Life Hacks.
diff --git a/src/KerbalLifeHacks/Hacks/BetterExperiments/BetterExperiments.cs b/src/KerbalLifeHacks/Hacks/BetterExperiments/BetterExperiments.cs
new file mode 100644
index 0000000..40826b9
--- /dev/null
+++ b/src/KerbalLifeHacks/Hacks/BetterExperiments/BetterExperiments.cs
@@ -0,0 +1,91 @@
+using System.Reflection.Emit;
+using HarmonyLib;
+using KSP.Sim.impl;
+using System.Reflection;
+using KSP.Modules;
+using KSP.Game.Science;
+
+namespace KerbalLifeHacks.Hacks.BetterExperiments;
+
+using PCMSE = PartComponentModule_ScienceExperiment;
+
+[Hack("Automatically resume paused experiments when they return to the appropriate region, and progress without waiting for animations", true)]
+public class BetterExperiments : BaseHack
+{
+ public static BetterExperiments Instance;
+
+ public override void OnInitialized()
+ {
+ Instance = this;
+ HarmonyInstance.PatchAll(typeof(BetterExperiments));
+ }
+
+ ///
+ /// Harmony transpiler to ignore part deployment state when running an
+ /// experiment, allowing it to progress during part animations.
+ ///
+ [HarmonyPatch(typeof(PCMSE), nameof(PCMSE.OnUpdate))]
+ [HarmonyTranspiler]
+ public static IEnumerable IgnorePartAnimationState(IEnumerable instructions)
+ {
+ FieldInfo dataScienceExpField = typeof(PCMSE)
+ .GetField(nameof(PCMSE.dataScienceExperiment), AccessTools.all);
+ FieldInfo isPartDeployedField = typeof(Data_ScienceExperiment)
+ .GetField(nameof(Data_ScienceExperiment.PartIsDeployed), AccessTools.all);
+
+ return new CodeMatcher(instructions)
+ .MatchStartForward(
+ new CodeMatch(OpCodes.Ldarg_0),
+ new CodeMatch(OpCodes.Ldfld, dataScienceExpField),
+ new CodeMatch(OpCodes.Ldfld, isPartDeployedField)
+ )
+ .Repeat(
+ matcher => matcher
+ // Rather than removing the instructions outright, we carefully
+ // replace them with nop/ldc to preserve labels.
+ .SetAndAdvance(OpCodes.Nop, null)
+ .SetAndAdvance(OpCodes.Nop, null)
+ .SetAndAdvance(OpCodes.Ldc_I4_1, null),
+ notFoundAction: message =>
+ Instance.Logger.LogWarning($"did not find experiment animation code! {message}")
+ )
+ .InstructionEnumeration();
+ }
+
+ ///
+ /// Automatically resume experiments when re-entering the correct research
+ /// location for a paused experiment.
+ ///
+ [HarmonyPatch(typeof(PCMSE), nameof(PCMSE.OnScienceSituationChanged))]
+ [HarmonyPostfix]
+ public static void AutomaticallyResumeExperiment(PCMSE __instance)
+ {
+ var newLocation = new ResearchLocation(
+ requiresRegion: __instance._currentLocation.RequiresRegion,
+ bodyName: __instance._currentLocation.BodyName,
+ scienceSituation: __instance._currentLocation.ScienceSituation,
+ scienceRegion: __instance._currentLocation.ScienceRegion
+ );
+
+ ref var standings = ref __instance.dataScienceExperiment.ExperimentStandings;
+ if (standings.Any(exp => exp.CurrentExperimentState == ExperimentState.RUNNING))
+ {
+ // Only one experiment can run at a time, so bail out
+ return;
+ }
+
+ for (int i = 0; i < standings.Count; i++)
+ {
+ newLocation.RequiresRegion = standings[i].RegionRequired;
+ if (standings[i].CurrentExperimentState == ExperimentState.PAUSED && standings[i].ExperimentLocation.Equals(newLocation))
+ {
+ Instance.Logger.LogInfo($"Resuming experiment {__instance.Part.PartName}/{standings[i].ExperimentID}");
+ // Experiment runtime can dip into negative values during high time warp,
+ // which causes RunExperiment to reset the experiment progress
+ standings[i].CurrentRunningTime = Math.Max(standings[i].CurrentRunningTime, 0.01);
+ __instance.RunExperiment(standings[i].ExperimentID);
+ return;
+ }
+ }
+ }
+}