diff --git a/GameData/KSPCommunityFixes/Settings.cfg b/GameData/KSPCommunityFixes/Settings.cfg index bc9e26c..72ccdfa 100644 --- a/GameData/KSPCommunityFixes/Settings.cfg +++ b/GameData/KSPCommunityFixes/Settings.cfg @@ -63,11 +63,6 @@ KSP_COMMUNITY_FIXES // Fix a bug causing the ROC manager to crash during loading with Kopernicus modified systems ROCValidationOOR = true - // Fix reaction wheels reporting incorrect available torque when the "Wheel Authority" - // tweakable is set below 100%. This fix instability issues with the stock SAS and other - // attitude controllers from various mods. - ReactionWheelsPotentialTorque = true - // Make the stock alarm to respect the day/year length defined by mods like // Kronometer. Fix the underlying AppUIMemberDateTime UI widget API to use the // custom IDateTimeFormatter if implemented. @@ -157,6 +152,11 @@ KSP_COMMUNITY_FIXES // Fix spread angle still being applied after decoupling symmetry-placed parachutes. ChutePhantomSymmetry = true + // Rewrite of the stock ITorqueProvider.GetPotentialTorque() implementations for the reaction wheels, + // rcs, gimbal and control surface stock partmodules. Fix various issues with the stock implementations, + // ranging from "minor" to "completely broken". + GetPotentialTorqueFixes = true + // ########################## // Obsolete bugfixes // ########################## @@ -210,6 +210,17 @@ KSP_COMMUNITY_FIXES // Add part actions for locking/unlocking part resources flow state. ResourceLockActions = true + // Slightly improves the stock SAS precision and implement an optional alternate attitude + // controller called "PreciseController", more stable and precise for in-space operations but + // not suited for atmospheric operations. + BetterSAS = true + + // Add two extra tweakables in the RCS module "Actuation Toggles", giving the ability to define + // a separate angle threshold for linear and rotation actuation. This allow to optimize efficiency + // of multi-nozzle RCS parts that are impossible to fine-tune with only the actuation toggles. + // This also add a potential torque/force readout to the actuation toggles PAW items. + RCSLimiter = true + // ########################## // Performance tweaks // ########################## @@ -302,6 +313,9 @@ KSP_COMMUNITY_FIXES // Prevent performance drops when there are in-progress comet sample or rover construction contracts ContractProgressEnumCache = true + // Disable control surfaces updates and actuation when the part isn't submerged or in an atmosphere + NoLiftInSpace = true + // ########################## // Modding // ########################## @@ -335,6 +349,11 @@ KSP_COMMUNITY_FIXES // upgrade scripts. ModUpgradePipeline = false + // Allow BaseField and related features (PAW controls, persistence...) to work when a custom BaseField + // is added to a BaseFieldList (ie, a Part or PartModule) with a host instance other than the + // BaseFieldList owner. Allow to dynamically add fields defined in other classes to a Part or PartModule. + BaseFieldListUseFieldHost = true + // ########################## // Localization tools // ########################## diff --git a/KSPCommunityFixes.sln b/KSPCommunityFixes.sln index ada51a7..254056c 100644 --- a/KSPCommunityFixes.sln +++ b/KSPCommunityFixes.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 16 -VisualStudioVersion = 16.0.30804.86 +# Visual Studio Version 17 +VisualStudioVersion = 17.3.32929.385 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "KSPCommunityFixes", "KSPCommunityFixes\KSPCommunityFixes.csproj", "{4E405C02-5AEB-4975-B26C-07582BB3FB15}" EndProject @@ -18,15 +18,20 @@ EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Profiling|Any CPU = Profiling|Any CPU Release|Any CPU = Release|Any CPU EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {4E405C02-5AEB-4975-B26C-07582BB3FB15}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {4E405C02-5AEB-4975-B26C-07582BB3FB15}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4E405C02-5AEB-4975-B26C-07582BB3FB15}.Profiling|Any CPU.ActiveCfg = Profiling|Any CPU + {4E405C02-5AEB-4975-B26C-07582BB3FB15}.Profiling|Any CPU.Build.0 = Profiling|Any CPU {4E405C02-5AEB-4975-B26C-07582BB3FB15}.Release|Any CPU.ActiveCfg = Release|Any CPU {4E405C02-5AEB-4975-B26C-07582BB3FB15}.Release|Any CPU.Build.0 = Release|Any CPU {32B601F4-B648-4C69-AA98-620FE7BA070C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {32B601F4-B648-4C69-AA98-620FE7BA070C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {32B601F4-B648-4C69-AA98-620FE7BA070C}.Profiling|Any CPU.ActiveCfg = Profiling|Any CPU + {32B601F4-B648-4C69-AA98-620FE7BA070C}.Profiling|Any CPU.Build.0 = Profiling|Any CPU {32B601F4-B648-4C69-AA98-620FE7BA070C}.Release|Any CPU.ActiveCfg = Release|Any CPU {32B601F4-B648-4C69-AA98-620FE7BA070C}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection diff --git a/KSPCommunityFixes/BugFixes/GetPotentialTorqueFixes.cs b/KSPCommunityFixes/BugFixes/GetPotentialTorqueFixes.cs new file mode 100644 index 0000000..b29135b --- /dev/null +++ b/KSPCommunityFixes/BugFixes/GetPotentialTorqueFixes.cs @@ -0,0 +1,1458 @@ +using System; +using System.Collections; +using HarmonyLib; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using KSP.Localization; +using KSPCommunityFixes.QoL; +using Unity.Profiling; +using UnityEngine; +using UnityEngine.UIElements; +using Random = UnityEngine.Random; + +/* +This patch is a rewrite of the stock implementations for `ITorqueProvider.GetPotentialTorque(out Vector3 pos, out Vector3 neg)`. +All 4 of the stock implementations have various issues and are generally giving unreliable (not to say plain wrong) results. +Those issues are commented in each patch, but to summarize : +- ModuleRectionWheels is mostly ok, its only issue is to ignore the state of "authority limiter" tweakable +- ModuleRCS is giving entirely random results, and the stcok implementation just doesn't make any sense. Note that compared to + other custom implementations (MechJeb, TCA, kOS), the KSPCF implementation account for the RCS module control scheme thrust + clamping and the actual thrust power (instead of the theoretical maximum). +- ModuleGimbal results are somewhat coherent, but their magnitude for pitch/yaw is wrong. They are underestimated for CoM-aligned + engines and vastly overestimated for engines placed off-CoM-center. +- ModuleControlSurface results are generally unreliable. Beside the fact that they can be randomly negative, the magnitude is + usually wrong and inconsistent. Depending on part placement relative to the CoM, they can return zero available torque or being + vastly overestimated. They also don't account for drag induced torque, and are almost entirely borked when a control surface is + in the deployed state. + +Note that the KSPCF GetPotentialTorque() implementations for ModuleControlSurface and especially for ModuleGimbal are more +computationally intensive that the stock ones. Profiling a stock Dynawing with RCS enabled during ascent show a ~30% degradation +when summing the vessel total available torque (~250 calls median : 0.31ms vs 0.24ms, frame time : 1.81% vs 1.46% ). Overall +this feels acceptable, but this is still is a non-negligible impact that will likely be noticeable in some cases (ie, +atmospheric flight with a large vessel having many gimballing engines and control surfaces). +The implementations are pretty naive and could probably be vastly optimized by someone with a better understanding than me of +the underlying maths and physics. + +The KSPCF implementations follow these conventions : +- in pos/neg : x is pitch, y is roll, z is yaw +- `pos` is the actuation induced torque for a positive FlightCtrlState (pitch = 1, roll, = 1 yaw = 1) control request +- `neg` is the actuation induced torque for a negative FlightCtrlState (pitch = -1, roll, = -1 yaw = -1) control request +- Contrary to the stock implementations, values are strictly the **actuation induced** torque (ie, the torque difference + between the neutral state and the actuated state). Especially in the case of ModuleGimbal, the stock implementation + returns the actuation torque plus the eventual "structural" torque due to an eventual CoM/CoT misalignement. +- Positive values mean actuation will induce a torque in the desired direction. Negatives values mean that actuation will + induce a torque in the opposite direction. For example, a negative `pos.x` value mean that for a positive roll actuation + (ctrlState.roll = 1), the torque provider will produce a torque inducing a negative roll, essentially reducing the total + available torque in that direction. This can notably happen with the stock aero control surfaces, due to their control + scheme being only based on their relative position/orientation to the vessel CoM and ignoring other factors like AoA. +- Like the stock implementations, they will give reliable results only if called from FixedUpdate(), including the control + state callbacks like `Vessel.OnFlyByWire` or `Vessel.On*AutopilotUpdate`. Calling them from the Update() loop will result + in an out-of-sync CoM position being used, producing garbage results. + +So in the context of the KSPCF patch, a correct implementation of a `GetVesselPotentialTorque()` method is : + ```cs + foreach (ITorqueProvider torqueProvider) + { + torqueProvider.GetPotentialTorque(out Vector3 pos, out Vector3 neg); + vesselPosTorque += pos; + vesselNegTorque += neg; + } + if (vesselPosTorque.x < 0f) vesselPosTorque.x = 0f; + if (vesselPosTorque.y < 0f) vesselPosTorque.y = 0f; + if (vesselPosTorque.z < 0f) vesselPosTorque.z = 0f; + if (vesselNegTorque.x < 0f) vesselNegTorque.x = 0f; + if (vesselNegTorque.y < 0f) vesselNegTorque.y = 0f; + if (vesselNegTorque.z < 0f) vesselNegTorque.z = 0f; + ``` + +Quick review of how the stock implementations are handled in the modding ecosystem : +- *It seems* Mechjeb doesn't care about a value being from "pos" or "neg", it assume a negative value from either of the vector3 + is a negative torque component (ie, if "pos.x" or "neg.x" is negative, it add that as negative available torque around x). + Ref : https://github.com/MuMech/MechJeb2/blob/f5c1193813da7d2e2e347f963dd4ee4b7fb11a90/MechJeb2/VesselState.cs#L1073-L1076 + Ref2 : https://github.com/MuMech/MechJeb2/blob/f5c1193813da7d2e2e347f963dd4ee4b7fb11a90/MechJeb2/Vector6.cs#L82-L93 + As it is, since MechJeb doesn't care for pos/neg and only consider the max, the patches will result in wrong values, but arguably + since it reimplement RCS they will only be "different kind of wrong" for control surfaces and gimbals, and probably "less wrong" + overall. +- kOS assume that the absolute value should be used. + (side note : kOS reimplements ModuleReactionWheel.GetPotentialTorque() to get around the authority limiter bug) + Ref : https://github.com/KSP-KOS/KOS/blob/7b7874153bc6c428404b3a1a913487b2fd0a9d99/src/kOS/Control/SteeringManager.cs#L658-L664 + The patches should apply mostly alright for kOS, at the exception of occasional negative values for gimbals and control surfaces + being treated as positive, resulting in a higher available torque than what it should. +- TCA doesn't seem aware of the possibility of negative values, it assume they are positive. + Ref : https://github.com/allista/ThrottleControlledAvionics/blob/b79a7372ab69616801f9953256b43ee872b90cf2/VesselProps/TorqueProps.cs#L167-L169 + The patches should more or less work for TCA, at the exception of negative gimbal/control surfaces values being treated incorrectly + and the reaction wheels authority limiter being applied twice. +- Atmospheric Autopilot replace the stock module implementation by its own and doesn't use the interface at all + Ref : https://github.com/Boris-Barboris/AtmosphereAutopilot/blob/master/AtmosphereAutopilot/SyncModuleControlSurface.cs +- FAR implements a replacement for ModuleControlSurface and consequently has a custom GetPotentialTorque() implementation. + It seems that it will *always* return positive "pos" values and negative "neg" values : + Ref : https://github.com/dkavolis/Ferram-Aerospace-Research/blob/95e127ae140b4be9699da8783d24dd8db726d753/FerramAerospaceResearch/LEGACYferram4/FARControllableSurface.cs#L294-L300 +*/ + +namespace KSPCommunityFixes.BugFixes +{ + class GetPotentialTorqueFixes : BasePatch + { + protected override Version VersionMin => new Version(1, 12, 3); + + protected override void ApplyPatches(List patches) + { + patches.Add(new PatchInfo( + PatchMethodType.Prefix, + AccessTools.Method(typeof(ModuleReactionWheel), nameof(ModuleReactionWheel.GetPotentialTorque)), + this)); + + patches.Add(new PatchInfo( + PatchMethodType.Prefix, + AccessTools.Method(typeof(ModuleRCS), nameof(ModuleRCS.GetPotentialTorque)), + this)); + + patches.Add(new PatchInfo( + PatchMethodType.Prefix, + AccessTools.Method(typeof(ModuleControlSurface), nameof(ModuleControlSurface.GetPotentialTorque)), + this)); + + patches.Add(new PatchInfo( + PatchMethodType.Postfix, + AccessTools.Method(typeof(ModuleControlSurface), nameof(ModuleControlSurface.OnStart)), + this)); + + patches.Add(new PatchInfo( + PatchMethodType.Prefix, + AccessTools.Method(typeof(ModuleGimbal), nameof(ModuleGimbal.GetPotentialTorque)), + this)); + + patches.Add(new PatchInfo( + PatchMethodType.Postfix, + AccessTools.Method(typeof(ModuleGimbal), nameof(ModuleGimbal.OnStart)), + this)); + + autoLOC_6001330_Pitch = Localizer.Format("#autoLOC_6001330"); + autoLOC_6001331_Yaw = Localizer.Format("#autoLOC_6001331"); + autoLOC_6001332_Roll = Localizer.Format("#autoLOC_6001332"); + + KSPCommunityFixes.Instance.StartCoroutine(CustomUpdate()); + } + + private IEnumerator CustomUpdate() + { + Repeat: + ModuleGimbalExtension.UpdateInstances(); + ModuleCtrlSrfExtension.UpdateInstances(); + yield return null; + goto Repeat; + } + + private static string autoLOC_6001330_Pitch; + private static string autoLOC_6001331_Yaw; + private static string autoLOC_6001332_Roll; + + static ProfilerMarker rwProfiler = new ProfilerMarker("KSPCF.ModuleReactionWheel.GetPotentialTorque"); + static ProfilerMarker rcsProfiler = new ProfilerMarker("KSPCF.ModuleRCS.GetPotentialTorque"); + static ProfilerMarker ctrlSrfProfiler = new ProfilerMarker("KSPCF.ModuleControlSurface.GetPotentialTorque"); + static ProfilerMarker ctrlSrfCacheProfiler = new ProfilerMarker("KSPCF.ModuleControlSurface.GetPotentialTorque.CacheCheck"); + static ProfilerMarker gimbalProfiler = new ProfilerMarker("KSPCF.ModuleGimbal.GetPotentialTorque"); + static ProfilerMarker gimbalCacheProfiler = new ProfilerMarker("KSPCF.ModuleGimbal.GetPotentialTorque.CacheCheck"); + + #region ModuleReactionWheel + + // Fix reaction wheels reporting incorrect available torque when the "Wheel Authority" tweakable is set below 100%. + static bool ModuleReactionWheel_GetPotentialTorque_Prefix(ModuleReactionWheel __instance, out Vector3 pos, out Vector3 neg) + { + rwProfiler.Begin(); + if (__instance.moduleIsEnabled && __instance.wheelState == ModuleReactionWheel.WheelState.Active && __instance.actuatorModeCycle != 2) + { + float authorityLimiter = __instance.authorityLimiter * 0.01f; + neg.x = pos.x = __instance.PitchTorque * authorityLimiter; + neg.y = pos.y = __instance.RollTorque * authorityLimiter; + neg.z = pos.z = __instance.YawTorque * authorityLimiter; + rwProfiler.End(); + return false; + } + + pos = neg = Vector3.zero; + rwProfiler.End(); + return false; + } + + #endregion + + #region ModuleRCS + + // The stock implementation is 100% broken, this is a complete replacement + static bool ModuleRCS_GetPotentialTorque_Prefix(ModuleRCS __instance, out Vector3 pos, out Vector3 neg) + { + rcsProfiler.Begin(); + pos = Vector3.zero; + neg = Vector3.zero; + + if (!__instance.moduleIsEnabled + || !__instance.rcsEnabled + || !__instance.rcs_active + || __instance.IsAdjusterBreakingRCS() + || __instance.isJustForShow + || __instance.flameout + || (__instance.part.ShieldedFromAirstream && !__instance.shieldedCanThrust) + || (!__instance.enablePitch && !__instance.enableRoll && !__instance.enableYaw)) + { + rcsProfiler.End(); + return false; + } + + float power = GetMaxRCSPower(__instance); + if (power < 0.0001f) + { + rcsProfiler.End(); + return false; + } + + Vector3 currentCoM = __instance.vessel.CurrentCoM; + + Quaternion controlRotation = __instance.vessel.ReferenceTransform.rotation; + Vector3 pitchCtrl = controlRotation * Vector3.right; + Vector3 rollCtrl = controlRotation * Vector3.up; + Vector3 yawCtrl = controlRotation * Vector3.forward; + + float minRotActuation; + bool checkActuation = RCSLimiter.moduleRCSExtensions != null; + if (checkActuation && RCSLimiter.moduleRCSExtensions.TryGetValue(__instance, out RCSLimiter.ModuleRCSExtension limits)) + { + minRotActuation = Mathf.Max(Mathf.Cos(limits.minRotationAlignement * Mathf.Deg2Rad), 0f); + } + else + { + minRotActuation = 0f; + checkActuation = __instance.fullThrust; + } + + for (int i = __instance.thrusterTransforms.Count - 1; i >= 0; i--) + { + Transform thruster = __instance.thrusterTransforms[i]; + + if (!thruster.gameObject.activeInHierarchy) + continue; + + Vector3 thrusterPosFromCoM = thruster.position - currentCoM; + Vector3 thrusterDirFromCoM = thrusterPosFromCoM.normalized; + Vector3 thrustDirection = __instance.useZaxis ? thruster.forward : thruster.up; + + float thrusterPower = power; + if (FlightInputHandler.fetch.precisionMode) + { + if (__instance.useLever) + { + float leverDistance = __instance.GetLeverDistance(thruster, thrustDirection, currentCoM); + if (leverDistance > 1f) + { + thrusterPower /= leverDistance; + } + } + else + { + thrusterPower *= __instance.precisionFactor; + } + } + + Vector3 thrusterThrust = thrustDirection * thrusterPower; + Vector3 thrusterTorque = Vector3.Cross(thrusterPosFromCoM, thrusterThrust); + // transform in vessel control space + thrusterTorque = __instance.vessel.ReferenceTransform.InverseTransformDirection(thrusterTorque); + + if (__instance.enablePitch && Math.Abs(thrusterTorque.x) > 0.0001f) + { + Vector3 pitchRot = Vector3.Cross(pitchCtrl, Vector3.ProjectOnPlane(thrusterDirFromCoM, pitchCtrl)); + float actuation = Vector3.Dot(thrustDirection, pitchRot.normalized); + + if (checkActuation) + { + float actuationMagnitude = Math.Abs(actuation); + if (actuationMagnitude < minRotActuation) + actuation = 0f; + else if (__instance.fullThrust && actuationMagnitude > __instance.fullThrustMin) + actuation = Math.Sign(actuation); + } + + if (actuation != 0f) + { + if (actuation > 0f) + pos.x += thrusterTorque.x * actuation; + else + neg.x += thrusterTorque.x * actuation; + } + } + + if (__instance.enableRoll && Math.Abs(thrusterTorque.y) > 0.0001f) + { + Vector3 rollRot = Vector3.Cross(rollCtrl, Vector3.ProjectOnPlane(thrusterDirFromCoM, rollCtrl)); + float actuation = Vector3.Dot(thrustDirection, rollRot.normalized); + + if (checkActuation) + { + float actuationMagnitude = Math.Abs(actuation); + if (actuationMagnitude < minRotActuation) + actuation = 0f; + else if (__instance.fullThrust && actuationMagnitude > __instance.fullThrustMin) + actuation = Math.Sign(actuation); + } + + if (actuation != 0f) + { + if (actuation > 0f) + pos.y += thrusterTorque.y * actuation; + else + neg.y += thrusterTorque.y * actuation; + } + } + + if (__instance.enableYaw && Math.Abs(thrusterTorque.z) > 0.0001f) + { + Vector3 yawRot = Vector3.Cross(yawCtrl, Vector3.ProjectOnPlane(thrusterDirFromCoM, yawCtrl)); + float actuation = Vector3.Dot(thrustDirection, yawRot.normalized); + + if (checkActuation) + { + float actuationMagnitude = Math.Abs(actuation); + if (actuationMagnitude < minRotActuation) + actuation = 0f; + else if (__instance.fullThrust && actuationMagnitude > __instance.fullThrustMin) + actuation = Math.Sign(actuation); + } + + if (actuation != 0f) + { + if (actuation > 0f) + pos.z += thrusterTorque.z * actuation; + else + neg.z += thrusterTorque.z * actuation; + } + } + } + +#if DEBUG + TorqueUIModule ui = __instance.part.FindModuleImplementing(); + if (ui != null) + { + ui.pos = pos; + ui.neg = neg; + } +#endif + rcsProfiler.End(); + return false; + } + + private static float GetMaxRCSPower(ModuleRCS mrcs) + { + if (!mrcs.requiresFuel) + return 1f; + + double flowMult = mrcs.flowMult; + if (mrcs.useThrustCurve) + flowMult *= mrcs.thrustCurveDisplay; + + float result = (float)(flowMult * mrcs.maxFuelFlow * mrcs.exhaustVel * mrcs.thrustPercentage * 0.01); + return result; + } + + #endregion + + #region ModuleControlSurface + + // The stock ModuleControlSurface.GetPotentialTorque() implementation has several issues and its results are overall very wrong : + // - It doesn't take drag forces into account (only lift). + // - It attempt to provide actuation torque (ie, the torque difference between pos/neg actuation and the neutral state) by substracting + // the neutral lift force vector to the actuated pos/neg force vectors. This is wrong and produce garbage results, it's the resulting + // torque from the neutral vector that should be substracted to the resulting torque from the pos/neg force vectors. + // - It entirely fails to correct the raw torque results for the pitch/roll/yaw actuation inversion and clamping logic, resulting in + // wrongly negative values and random inversions of the neg / pos value. + // - For reasons that escape my understanding entirely, it multiply the torque results by the vessel CoM to Part vector, resulting in + // more non-sense sign inversions and a near squaring of the result magnitude. + // - It generally doesn't handle correctly control surfaces in the deployed state. + + // This reimplementation fixes all the above issues. + // It still has a few shortcomings. Notably, it partially reuse results from the previous FixedUpdate() and mix them with current values. + // This mean the results are slightly wrong, but this saves quite a bit of extra processing in that already quite performance heavy method, + // and the error magnitude shouldn't matter for potential applications of GetPotentialTorque(). + // It shall also be noted that the result magnitude is an approximation when the actuation is clamped by the module (ie, when the control + // surface neutral state isn't aligned with the airflow), with the error being greater when the allowed actuation is lower. + + // Note that the results can still return negative components. The meaning of a negative value is that the actuation of that component will + // induce a torque in the opposite direction. For example, a negative pos.x value mean that for a positive roll actuation (ctrlState.roll > 0), + // the control surface will produce a torque incuding a negative roll, essentially reducing the total available torque in that direction. + + + // stuff we don't have in the editor : nVel, Qlift, part.machNumber, Qdrag, baseLiftForce + + private class ModuleCtrlSrfExtension + { + private static Dictionary instances = new Dictionary(); + + public static ModuleCtrlSrfExtension Get(ModuleControlSurface module) + { + if (instances.TryGetValue(module, out ModuleCtrlSrfExtension gimbalExt)) + return gimbalExt; + + return new ModuleCtrlSrfExtension(module); + } + + private ModuleControlSurface module; + + public Vector3 pos; + public Vector3 neg; + + public Vector3 worldCoM; + public Vector3 localCoM; + public Vector3 nVel; + public Vector3 neutralForce; + public double QLift; + public double QDrag; + public float currentDeployAngle; + public float machNumber; + + public float lastTime; + public Vector3 lastBaseLiftForce; + private bool lastPitch; + private bool lastRoll; + private bool lastYaw; + private float QLiftThreshold; + private float timeThreshold; + + private bool pawTorqueEnabled; + private BaseField ignorePitchField; + private BaseField ignoreRollField; + private BaseField ignoreYawField; + + public ModuleCtrlSrfExtension(ModuleControlSurface module) + { + this.module = module; + + ignorePitchField = module.Fields[nameof(ModuleControlSurface.ignorePitch)]; + ignoreRollField = module.Fields[nameof(ModuleControlSurface.ignoreRoll)]; + ignoreYawField = module.Fields[nameof(ModuleControlSurface.ignoreYaw)]; + + module.part.OnJustAboutToBeDestroyed += OnDestroy; + instances.Add(module, this); + + QLiftThreshold = Random.Range(0.04f, 0.06f); + timeThreshold = Random.Range(0.75f, 1.25f); + } + + public void OnDestroy() + { + module.part.OnJustAboutToBeDestroyed -= OnDestroy; + instances.Remove(module); + module = null; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void UpdateCachedState(Vector3 worldCoM, Vector3 localCoM) + { + this.worldCoM = worldCoM; + this.localCoM = localCoM; + lastTime = Time.fixedTime; + nVel = module.nVel; + QLift = module.Qlift; + QDrag = module.Qdrag; + machNumber = (float)module.part.machNumber; + + if (module.deploy) + { + currentDeployAngle = module.currentDeployAngle; + Vector3 rhsNeutral = Quaternion.AngleAxis(currentDeployAngle, module.baseTransform.rotation * Vector3.right) * module.baseTransform.forward; + float dotNeutral = Vector3.Dot(nVel, rhsNeutral); + float absDotNeutral = Mathf.Abs(dotNeutral); + neutralForce = module.GetLiftVector(rhsNeutral, dotNeutral, absDotNeutral, QLift, machNumber) * module.ctrlSurfaceArea; + neutralForce += GetDragForce(module, this, absDotNeutral); + } + else + { + currentDeployAngle = 0f; + neutralForce = module.baseLiftForce * module.ctrlSurfaceArea; + neutralForce += GetDragForce(module, this, module.absDot); + } + + lastBaseLiftForce = module.baseLiftForce; + lastPitch = module.ignorePitch; + lastRoll = module.ignoreRoll; + lastYaw = module.ignoreYaw; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool IsCacheValid(Vector3 worldCoM, Vector3 localCoM) + { + if ((QLift > 0.0 && Math.Abs((module.Qlift / QLift) - 1.0) > QLiftThreshold) + || (this.localCoM - localCoM).sqrMagnitude > 0.1f * 0.1f + || (lastBaseLiftForce - module.baseLiftForce).sqrMagnitude > 0.1f * 0.1f + || Time.fixedTime > lastTime + timeThreshold + || currentDeployAngle != module.currentDeployAngle + || lastPitch != module.ignorePitch || lastRoll != module.ignoreRoll || lastYaw != module.ignoreYaw) + { + UpdateCachedState(worldCoM, localCoM); + return false; + } + + return true; + } + + public void UpdateEditorState(Transform referenceTransform, EditorPhysics editorPhysics) + { + if (editorPhysics.atmDensity == 0.0) + return; + + worldCoM = editorPhysics.CoM; + + nVel = referenceTransform.up; // velocity normalized + + currentDeployAngle = module.deploy ? module.currentDeployAngle : 0f; + + double dynamicPressureKpa = editorPhysics.atmDensity; + double speed = 100.0; // just assume 100m/s for now + + dynamicPressureKpa *= 0.0005 * speed * speed; + QLift = dynamicPressureKpa * 1000.0; + QDrag = dynamicPressureKpa * 1000.0; + + double speedOfSound = editorPhysics.body.GetSpeedOfSound(editorPhysics.atmStaticPressureKpa, editorPhysics.atmDensity); + if (speedOfSound > 0.0) + machNumber = (float)(speed / speedOfSound); + else + machNumber = 0f; + + Vector3 rhsNeutral = Quaternion.AngleAxis(currentDeployAngle, module.baseTransform.rotation * Vector3.right) * module.baseTransform.forward; + float dotNeutral = Vector3.Dot(nVel, rhsNeutral); + float absDotNeutral = Mathf.Abs(dotNeutral); + neutralForce = GetLiftForce(module, this, rhsNeutral, dotNeutral, absDotNeutral) * module.ctrlSurfaceArea; + neutralForce += GetDragForce(module, this, absDotNeutral); + } + + public static void UpdateInstances() + { + foreach (ModuleCtrlSrfExtension moduleExtension in instances.Values) + { + try + { + if (moduleExtension.module.isActiveAndEnabled && !moduleExtension.module.displaceVelocity) + moduleExtension.UpdatePAW(); + } + catch (Exception e) + { + Debug.LogException(e); + } + } + } + + private void UpdatePAW() + { + if (!ActuationToggleDisplayed(module)) + { + if (pawTorqueEnabled) + DisablePAWTorque(); + + return; + } + + ModuleControlSurface_GetPotentialTorque_Prefix(module, out Vector3 pos, out Vector3 neg); + + if (pos == Vector3.zero && neg == Vector3.zero) + { + if (pawTorqueEnabled) + DisablePAWTorque(); + + return; + } + + pawTorqueEnabled = true; + + if (!module.ignorePitch) + SetToggleGuiName(ignorePitchField, $"{autoLOC_6001330_Pitch}: {Math.Round(pos.x, 3):G3} / {-Math.Round(neg.x, 3):G3} kNm"); + else + SetToggleGuiName(ignorePitchField, autoLOC_6001330_Pitch); + + if (!module.ignoreRoll) + SetToggleGuiName(ignoreRollField, $"{autoLOC_6001332_Roll}: {Math.Round(pos.y, 3):G3} / {-Math.Round(neg.y, 3):G3} kNm"); + else + SetToggleGuiName(ignoreRollField, autoLOC_6001332_Roll); + + if (!module.ignoreYaw) + SetToggleGuiName(ignoreYawField, $"{autoLOC_6001331_Yaw}: {Math.Round(pos.z, 3):G3} / {-Math.Round(neg.z, 3):G3} kNm"); + else + SetToggleGuiName(ignoreYawField, autoLOC_6001331_Yaw); + } + + private void DisablePAWTorque() + { + pawTorqueEnabled = false; + SetToggleGuiName(ignorePitchField, autoLOC_6001330_Pitch); + SetToggleGuiName(ignoreRollField, autoLOC_6001332_Roll); + SetToggleGuiName(ignoreYawField, autoLOC_6001331_Yaw); + } + + static bool ActuationToggleDisplayed(ModuleControlSurface module) + { + if (module.part.PartActionWindow.IsNullOrDestroyed() || !module.part.PartActionWindow.isActiveAndEnabled) + return false; + + return true; + } + + private static void SetToggleGuiName(BaseField baseField, string guiName) + { + baseField.guiName = guiName; + + UIPartActionToggle toggle; + + if (baseField.uiControlEditor.partActionItem.IsNotNullOrDestroyed()) + toggle = (UIPartActionToggle)baseField.uiControlEditor.partActionItem; + else if (baseField.uiControlFlight.partActionItem.IsNotNullOrDestroyed()) + toggle = (UIPartActionToggle)baseField.uiControlFlight.partActionItem; + else + return; + + toggle.fieldName.text = guiName; + ((RectTransform)toggle.fieldName.transform).sizeDelta = new Vector2(150f, toggle.fieldName.rectTransform.sizeDelta.y); + } + } + + static void ModuleControlSurface_OnStart_Postfix(ModuleControlSurface __instance) + { + ModuleCtrlSrfExtension.Get(__instance); + } + + static bool ModuleControlSurface_GetPotentialTorque_Prefix(ModuleControlSurface __instance, out Vector3 pos, out Vector3 neg) + { + ctrlSrfProfiler.Begin(); + pos = Vector3.zero; + neg = Vector3.zero; + + bool isEditor = HighLogic.LoadedScene == GameScenes.EDITOR; + + if (isEditor) + { + if (__instance.ignorePitch && __instance.ignoreYaw && __instance.ignoreRoll) + { + ctrlSrfProfiler.End(); + return false; + } + } + else + { + if (__instance.Qlift < 1.0 || (__instance.ignorePitch && __instance.ignoreYaw && __instance.ignoreRoll)) + { + ctrlSrfProfiler.End(); + return false; + } + } + + if (__instance.displaceVelocity) + { + if (isEditor) + { + ctrlSrfProfiler.End(); + return false; + } + + // This case is for handling "propeller blade" control surfaces. Those have a completely different behavior and + // actuation scheme (and why this wasn't implemented as a separate module is beyond my understanding). + // This is the stock GetPotentialTorque() implementation for them, I've no idea how correct it is and just don't + // have the motivation to investigate. + + Vector3 potentialForcePos = __instance.GetPotentialLift(true); + Vector3 potentialForceNeg = __instance.GetPotentialLift(false); + float magnitude = __instance.vesselBladeLiftReference.magnitude; + pos = Vector3.Dot(potentialForcePos, __instance.vesselBladeLiftReference) * __instance.potentialBladeControlTorque / magnitude; + neg = Vector3.Dot(potentialForceNeg, __instance.vesselBladeLiftReference) * __instance.potentialBladeControlTorque / magnitude; + } + else + { + // The stock method doesn't handle correctly the deployed state : + // - It always apply `currentDeployAngle` in the AngleAxis() call, but that field is updated only if `mcs.deploy == true`, and + // it isn't reverted to 0 if deploy changes from true to false, resulting in the deployed angle still being applied after un-deploying. + // - It always substract `baseLiftForce` which is always the non-deployed lift vector, resulting in the positive deflection being twice + // what it should be and the negative deflection being always zero. + + ModuleCtrlSrfExtension moduleExt; + Transform vesselReferenceTransform; + + if (isEditor) + { + if (!EditorPhysics.TryGetAndUpdate(out EditorPhysics editorPhysics) || editorPhysics.atmDensity == 0.0) + { + ctrlSrfProfiler.End(); + return false; + } + + vesselReferenceTransform = editorPhysics.referenceTransform; + moduleExt = ModuleCtrlSrfExtension.Get(__instance); + moduleExt.UpdateEditorState(vesselReferenceTransform, editorPhysics); + } + else + { + vesselReferenceTransform = __instance.vessel.ReferenceTransform; + + ctrlSrfCacheProfiler.Begin(); + + moduleExt = ModuleCtrlSrfExtension.Get(__instance); + if (moduleExt.IsCacheValid(__instance.vessel.CurrentCoM, vesselReferenceTransform.InverseTransformPoint(__instance.vessel.CurrentCoM))) + { + pos = moduleExt.pos; + neg = moduleExt.neg; + ctrlSrfCacheProfiler.End(); + ctrlSrfProfiler.End(); + return false; + } + ctrlSrfCacheProfiler.End(); + } + + Vector3 potentialForcePos = GetPotentialLiftAndDrag(__instance, moduleExt, moduleExt.currentDeployAngle, true); + Vector3 potentialForceNeg = GetPotentialLiftAndDrag(__instance, moduleExt, moduleExt.currentDeployAngle, false); + + Vector3 partPosition = __instance.part.Rigidbody.worldCenterOfMass - moduleExt.worldCoM; + + Vector3 posTorque = vesselReferenceTransform.InverseTransformDirection(Vector3.Cross(partPosition, potentialForcePos)); + Vector3 negTorque = vesselReferenceTransform.InverseTransformDirection(Vector3.Cross(partPosition, potentialForceNeg)); + + Vector3 neutralTorque = vesselReferenceTransform.InverseTransformDirection(Vector3.Cross(partPosition, moduleExt.neutralForce)); + + posTorque -= neutralTorque; + negTorque -= neutralTorque; + + // At this point, we have raw torque results for two given actuations. However, GetPotentialTorque() is supposed to + // represent the torque produced by pitch/roll/yaw requests. We need to determine which actuation is applied for a pos=(1,1,1) + // and neg=(-1,-1,-1) ctrlState, then swap the raw torque components accordingly. Said otherwise, to take an example, + // we need to answer : does a positive pitch request results in a positive or negative actuation ? + // Additionally, ModuleControlSurface will clamp actuation magnitude depending on the surface orientation, and apply an + // additional (weird) clamping for the deployed state. + + // The following code is essentially derived from ModuleControlSurface.FixedCtrlSurfaceUpdate(), which is the method responsible + // for updating the control surface angle according to the vessel.ctrlState pitch/roll/yaw request. + + float deployAction; + if (__instance.deploy) + { + deployAction = __instance.usesMirrorDeploy + ? ((__instance.deployInvert ? (-1f) : 1f) * (__instance.partDeployInvert ? (-1f) : 1f) * (__instance.mirrorDeploy ? (-1f) : 1f)) + : ((__instance.deployInvert ? (-1f) : 1f) * Mathf.Sign((Quaternion.Inverse(vesselReferenceTransform.rotation) * (__instance.baseTransform.position - moduleExt.worldCoM)).x)); + + deployAction *= -1f; + } + else + { + deployAction = 0f; + } + + Vector3 comRelPos = __instance.baseTransform.InverseTransformPoint(moduleExt.worldCoM); + +#if DEBUG + Vector3 posAction = Vector3.zero; + Vector3 negAction = Vector3.zero; +#endif + + if (!__instance.ignorePitch) + { + Vector3 pitchVector = vesselReferenceTransform.rotation * new Vector3(1f, 0f, 0f); + float pitchActionPos = Vector3.Dot(pitchVector, __instance.baseTransform.rotation * Vector3.right); + if (comRelPos.y < 0f) + pitchActionPos = -pitchActionPos; + + float pitchActionNeg = -pitchActionPos; + + if (__instance.deploy) + { + pitchActionPos = Mathf.Clamp(pitchActionPos + deployAction, -1.5f, 1.5f) - deployAction; + pitchActionNeg = Mathf.Clamp(pitchActionNeg + deployAction, -1.5f, 1.5f) - deployAction; + } + + // I hope I got this right. TBH, this was mostly a trial and error job. + // - the control surface actuation direction depends on sign of the action + // - then we clamp and inverse the raw torque by the action + // note that this direct scaling is a rough approximation, as the torque output vs actuation function isn't linear, + // but the whole thing is computationally intensive (and complex) enough already... + if (pitchActionPos > 0f) + { + pos.x = negTorque.x * pitchActionPos; + neg.x = posTorque.x * pitchActionNeg; + } + else + { + pos.x = posTorque.x * pitchActionNeg; + neg.x = negTorque.x * pitchActionPos; + } +#if DEBUG + posAction.x = pitchActionPos; + negAction.x = pitchActionNeg; +#endif + } + + if (!__instance.ignoreYaw) + { + Vector3 yawVector = vesselReferenceTransform.rotation * new Vector3(0f, 0f, 1f); + float yawActionPos = Vector3.Dot(yawVector, __instance.baseTransform.rotation * Vector3.right); + if (comRelPos.y < 0f) + yawActionPos = -yawActionPos; + + float yawActionNeg = -yawActionPos; + + if (__instance.deploy) + { + yawActionPos = Mathf.Clamp(yawActionPos + deployAction, -1.5f, 1.5f) - deployAction; + yawActionNeg = Mathf.Clamp(yawActionNeg + deployAction, -1.5f, 1.5f) - deployAction; + } + + if (yawActionPos > 0f) + { + pos.z = negTorque.z * yawActionPos; + neg.z = posTorque.z * yawActionNeg; + } + else + { + pos.z = posTorque.z * yawActionNeg; + neg.z = negTorque.z * yawActionPos; + } +#if DEBUG + posAction.z = yawActionPos; + negAction.z = yawActionNeg; +#endif + } + + if (!__instance.ignoreRoll) + { + // optimization note : we could get rollAction by doing `rollAction = mcs.roll / ctrlStateRoll` where + // ctrlStateRoll is the `vessel.ctrlState.roll` value from the last fixedUpdate(). + // But implementing that would be a mess, and the value would be slightly wrong due to being a frame outdated + // (altough this won't matter much, the overall GetPotentialTorque() implementation already rely on a bunch of + // one-frame outdated values for performance optimization reasons) + + Vector3 rhs = new Vector3(comRelPos.x, 0f, comRelPos.z); + + float rollActionPos = Vector3.Dot(Vector3.right, rhs) + * (1f - (Mathf.Abs(Vector3.Dot(rhs.normalized, Quaternion.Inverse(__instance.baseTransform.rotation) * vesselReferenceTransform.up)) * 0.5f + 0.5f)) + * Mathf.Sign(Vector3.Dot(__instance.baseTransform.up, vesselReferenceTransform.up)) + * Mathf.Sign(__instance.ctrlSurfaceRange) + * -1f; + + rollActionPos = Mathf.Clamp(rollActionPos, -1f, 1f); + + float rollActionNeg = -rollActionPos; + + if (__instance.deploy) + { + rollActionPos = Mathf.Clamp(rollActionPos + deployAction, -1.5f, 1.5f) - deployAction; + rollActionNeg = Mathf.Clamp(rollActionNeg + deployAction, -1.5f, 1.5f) - deployAction; + } + + if (rollActionPos > 0f) + { + pos.y = negTorque.y * rollActionPos; + neg.y = posTorque.y * rollActionNeg; + } + else + { + pos.y = posTorque.y * rollActionNeg; + neg.y = negTorque.y * rollActionPos; + } +#if DEBUG + posAction.y = rollActionPos; + negAction.y = rollActionNeg; +#endif + } + + moduleExt.pos = pos; + moduleExt.neg = neg; + +#if DEBUG + TorqueUIModule ui = __instance.part.FindModuleImplementing(); + if (ui != null) + { + ui.pos = pos; + ui.neg = neg; + + ui.Fields["spos"].guiActive = true; + ui.spos = posTorque; + + ui.Fields["sneg"].guiActive = true; + ui.sneg = negTorque; + + ui.Fields["posAction"].guiActive = true; + ui.posAction = posAction; + + ui.Fields["negAction"].guiActive = true; + ui.negAction = negAction; + } +#endif + } + + ctrlSrfProfiler.End(); + return false; + } + + private static Vector3 GetPotentialLiftAndDrag(ModuleControlSurface mcs, ModuleCtrlSrfExtension moduleExt, float deployAngle, bool positiveDeflection) + { + float deflectionDir = positiveDeflection ? 1f : -1f; + float angle = deployAngle + (deflectionDir * mcs.ctrlSurfaceRange * mcs.authorityLimiter * 0.01f); + Vector3 rhs = Quaternion.AngleAxis(angle, mcs.baseTransform.rotation * Vector3.right) * mcs.baseTransform.forward; + float dot = Vector3.Dot(moduleExt.nVel, rhs); + float absDot = Mathf.Abs(dot); + Vector3 result = GetLiftForce(mcs, moduleExt, rhs, dot, absDot) * mcs.ctrlSurfaceArea; + result += GetDragForce(mcs, moduleExt, absDot); + return result; + } + + private static Vector3 GetLiftForce(ModuleControlSurface mcs, ModuleCtrlSrfExtension moduleExt, Vector3 liftVector, float liftDot, float absDot) + { + if (mcs.nodeEnabled && mcs.attachNode.attachedPart != null) + { + return Vector3.zero; + } + float liftScalar = Mathf.Sign(liftDot) * mcs.liftCurve.Evaluate(absDot) * mcs.liftMachCurve.Evaluate(moduleExt.machNumber); + liftScalar *= mcs.deflectionLiftCoeff; + if (liftScalar != 0f && !float.IsNaN(liftScalar)) + { + liftScalar = (float)(moduleExt.QLift * PhysicsGlobals.LiftMultiplier * liftScalar); + if (mcs.perpendicularOnly) + { + Vector3 vector = -liftVector * liftScalar; + vector = Vector3.ProjectOnPlane(vector, -moduleExt.nVel); + return vector; + } + return -liftVector * liftScalar; + } + return Vector3.zero; + } + + private static Vector3 GetDragForce(ModuleControlSurface mcs, ModuleCtrlSrfExtension moduleExt, float absDot) + { + if (!mcs.useInternalDragModel || (mcs.nodeEnabled && mcs.attachNode.attachedPart.IsNotNullOrDestroyed())) + return Vector3.zero; + + float dragScalar = mcs.dragCurve.Evaluate(absDot) * mcs.dragMachCurve.Evaluate(moduleExt.machNumber); + dragScalar *= mcs.deflectionLiftCoeff; + if (dragScalar != 0f && !float.IsNaN(dragScalar)) + { + dragScalar = (float)moduleExt.QDrag * dragScalar * PhysicsGlobals.LiftDragMultiplier; + return -moduleExt.nVel * dragScalar * mcs.ctrlSurfaceArea; + } + + return Vector3.zero; + } + +#endregion + +#region ModuleGimbal + + private class ModuleGimbalExtension + { + private static Dictionary instances = new Dictionary(); + + public static ModuleGimbalExtension Get(ModuleGimbal module) + { + if (instances.TryGetValue(module, out ModuleGimbalExtension gimbalExt)) + return gimbalExt; + + return new ModuleGimbalExtension(module); + } + + private ModuleGimbal module; + + public Vector3 pos; + public Vector3 neg; + + private Vector3 lastLocalCoM; + private float lastThrustForce; + private float lastTime; + private float lastGimbalLimiter; + private bool lastPitch; + private bool lastRoll; + private bool lastYaw; + private float timeThreshold; + + private bool pawTorqueEnabled; + private BaseField enablePitchField; + private BaseField enableRollField; + private BaseField enableYawField; + + public ModuleGimbalExtension(ModuleGimbal module) + { + this.module = module; + + enablePitchField = module.Fields[nameof(ModuleGimbal.enablePitch)]; + enableRollField = module.Fields[nameof(ModuleGimbal.enableRoll)]; + enableYawField = module.Fields[nameof(ModuleGimbal.enableYaw)]; + + module.part.OnJustAboutToBeDestroyed += OnDestroy; + instances.Add(module, this); + + timeThreshold = Random.Range(0.75f, 1.25f); + } + + public void OnDestroy() + { + module.part.OnJustAboutToBeDestroyed -= OnDestroy; + instances.Remove(module); + module = null; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void UpdateLastState(Vector3 localCoM, float thrustForce) + { + lastLocalCoM = localCoM; + lastThrustForce = thrustForce; + lastTime = Time.fixedTime; + lastGimbalLimiter = module.gimbalLimiter; + lastPitch = module.enablePitch; + lastRoll = module.enableRoll; + lastYaw = module.enableYaw; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool IsCacheValid(Vector3 localCoM, float thrustForce) + { + if (Math.Abs(lastThrustForce - thrustForce) > 1.0 + || (lastLocalCoM - localCoM).sqrMagnitude > 0.1f * 0.1f + || Time.fixedTime > lastTime + timeThreshold + || lastGimbalLimiter != module.gimbalLimiter + || lastPitch != module.enablePitch || lastRoll != module.enableRoll || lastYaw != module.enableYaw) + { + UpdateLastState(localCoM, thrustForce); + return false; + } + + return true; + } + + public static void UpdateInstances() + { + foreach (ModuleGimbalExtension moduleExtension in instances.Values) + { + try + { + if (moduleExtension.module.isActiveAndEnabled) + moduleExtension.UpdatePAW(); + } + catch (Exception e) + { + Debug.LogException(e); + } + } + } + + private void UpdatePAW() + { + if (!ActuationToggleDisplayed(module)) + { + if (pawTorqueEnabled) + DisablePAWTorque(); + + return; + } + + ModuleGimbal_GetPotentialTorque_Prefix(module, out Vector3 pos, out Vector3 neg); + + if (pos == Vector3.zero && neg == Vector3.zero) + { + if (pawTorqueEnabled) + DisablePAWTorque(); + + return; + } + + pawTorqueEnabled = true; + + if (module.enablePitch) + SetToggleGuiName(enablePitchField, $"{autoLOC_6001330_Pitch}: {Math.Round(pos.x, 3):G3} / {-Math.Round(neg.x, 3):G3} kNm"); + else + SetToggleGuiName(enablePitchField, autoLOC_6001330_Pitch); + + if (module.enableRoll) + SetToggleGuiName(enableRollField, $"{autoLOC_6001332_Roll}: {Math.Round(pos.y, 3):G3} / {-Math.Round(neg.y, 3):G3} kNm"); + else + SetToggleGuiName(enableRollField, autoLOC_6001332_Roll); + + if (module.enableYaw) + SetToggleGuiName(enableYawField, $"{autoLOC_6001331_Yaw}: {Math.Round(pos.z, 3):G3} / {-Math.Round(neg.z, 3):G3} kNm"); + else + SetToggleGuiName(enableYawField, autoLOC_6001331_Yaw); + } + + private void DisablePAWTorque() + { + pawTorqueEnabled = false; + SetToggleGuiName(enablePitchField, autoLOC_6001330_Pitch); + SetToggleGuiName(enableRollField, autoLOC_6001332_Roll); + SetToggleGuiName(enableYawField, autoLOC_6001331_Yaw); + } + + static bool ActuationToggleDisplayed(ModuleGimbal module) + { + if (!module.showToggles || !module.currentShowToggles) + return false; + + if (module.part.PartActionWindow.IsNullOrDestroyed() || !module.part.PartActionWindow.isActiveAndEnabled) + return false; + + return true; + } + + private static void SetToggleGuiName(BaseField baseField, string guiName) + { + baseField.guiName = guiName; + + UIPartActionToggle toggle; + + if (baseField.uiControlEditor.partActionItem.IsNotNullOrDestroyed()) + toggle = (UIPartActionToggle)baseField.uiControlEditor.partActionItem; + else if (baseField.uiControlFlight.partActionItem.IsNotNullOrDestroyed()) + toggle = (UIPartActionToggle)baseField.uiControlFlight.partActionItem; + else + return; + + toggle.fieldName.text = guiName; + ((RectTransform)toggle.fieldName.transform).sizeDelta = new Vector2(150f, toggle.fieldName.rectTransform.sizeDelta.y); + } + } + + static void ModuleGimbal_OnStart_Postfix(ModuleGimbal __instance) + { + ModuleGimbalExtension.Get(__instance); + } + + static bool ModuleGimbal_GetPotentialTorque_Prefix(ModuleGimbal __instance, out Vector3 pos, out Vector3 neg) + { + gimbalProfiler.Begin(); + + pos = Vector3.zero; + neg = Vector3.zero; + + bool isEditor = HighLogic.LoadedScene == GameScenes.EDITOR; + + if (isEditor) + { + if (__instance.gimbalLock + || !__instance.moduleIsEnabled + || (!__instance.enablePitch && !__instance.enableRoll && !__instance.enableYaw)) + { + gimbalProfiler.End(); + return false; + } + } + else + { + if (__instance.gimbalLock + || !__instance.gimbalActive + || !__instance.moduleIsEnabled + || (!__instance.enablePitch && !__instance.enableRoll && !__instance.enableYaw)) + { + gimbalProfiler.End(); + return false; + } + } + + // ensure we don't create a cache entry when the part is destroyed + if (__instance.part.State == PartStates.DEAD) + { + gimbalProfiler.End(); + return false; + } + + if (__instance.engineMultsList == null) + __instance.CreateEngineList(); + + ModuleGimbalExtension gimbalCache; + Vector3 worldCoM; + Vector3 localCoM; + Transform vesselReferenceTransform; + int transformIndex; + EditorPhysics editorPhysics; + + if (isEditor) + { + if (!EditorPhysics.TryGetAndUpdate(out editorPhysics)) + { + gimbalProfiler.End(); + return false; + } + + worldCoM = editorPhysics.CoM; + vesselReferenceTransform = editorPhysics.referenceTransform; + localCoM = vesselReferenceTransform.InverseTransformPoint(worldCoM); + gimbalCache = ModuleGimbalExtension.Get(__instance); + } + else + { + editorPhysics = null; + + float totalThrust = 0f; + transformIndex = __instance.gimbalTransforms.Count; + while (transformIndex-- > 0) + { + int engineIndex = __instance.engineMultsList[transformIndex].Count; + while (engineIndex-- > 0) + totalThrust += __instance.engineMultsList[transformIndex][engineIndex].Key.finalThrust; + } + + if (totalThrust == 0f) + { + gimbalProfiler.End(); + return false; + } + + worldCoM = __instance.vessel.CurrentCoM; + vesselReferenceTransform = __instance.vessel.ReferenceTransform; + localCoM = vesselReferenceTransform.InverseTransformPoint(worldCoM); + + gimbalCacheProfiler.Begin(); + + gimbalCache = ModuleGimbalExtension.Get(__instance); + if (gimbalCache.IsCacheValid(localCoM, totalThrust)) + { + pos = gimbalCache.pos; + neg = gimbalCache.neg; + gimbalCacheProfiler.End(); + gimbalProfiler.End(); + return false; + } + gimbalCacheProfiler.End(); + } + + transformIndex = __instance.gimbalTransforms.Count; + while (transformIndex-- > 0) + { + List> engines = __instance.engineMultsList[transformIndex]; + + Transform gimbalTransform = __instance.gimbalTransforms[transformIndex]; + + // this is the neutral gimbalTransform.localRotation + Quaternion neutralLocalRot = __instance.initRots[transformIndex]; + Quaternion neutralWorldRot = gimbalTransform.parent.rotation * neutralLocalRot; + // get the rotation between the current gimbal rotation and the neutral rotation + Quaternion gimbalWorldRotToNeutral = neutralWorldRot * Quaternion.Inverse(gimbalTransform.rotation); + + Vector3 neutralTorque = Vector3.zero; + Vector3 pitchPosTorque = Vector3.zero; + Vector3 pitchNegTorque = Vector3.zero; + Vector3 rollPosTorque = Vector3.zero; + Vector3 rollNegTorque = Vector3.zero; + Vector3 yawPosTorque = Vector3.zero; + Vector3 yawNegTorque = Vector3.zero; + + Vector3 controlPoint = vesselReferenceTransform.InverseTransformPoint(gimbalTransform.position); + + bool inversedControl = localCoM.y < controlPoint.y; + + Quaternion pitchPosActuation; + Quaternion pitchNegActuation; + if (__instance.enablePitch) + { + pitchPosActuation = GetGimbalWorldRotation(__instance, vesselReferenceTransform, gimbalTransform, Vector3.right, controlPoint, inversedControl, neutralLocalRot, neutralWorldRot); + pitchNegActuation = GetGimbalWorldRotation(__instance, vesselReferenceTransform, gimbalTransform, Vector3.left, controlPoint, inversedControl, neutralLocalRot, neutralWorldRot); + } + else + { + pitchPosActuation = Quaternion.identity; + pitchNegActuation = Quaternion.identity; + } + + Quaternion rollPosActuation; + Quaternion rollNegActuation; + if (__instance.enableRoll) + { + rollPosActuation = GetGimbalWorldRotation(__instance, vesselReferenceTransform, gimbalTransform, Vector3.up, controlPoint, inversedControl, neutralLocalRot, neutralWorldRot); + rollNegActuation = GetGimbalWorldRotation(__instance, vesselReferenceTransform, gimbalTransform, Vector3.down, controlPoint, inversedControl, neutralLocalRot, neutralWorldRot); + } + else + { + rollPosActuation = Quaternion.identity; + rollNegActuation = Quaternion.identity; + } + + Quaternion yawPosActuation; + Quaternion yawNegActuation; + if (__instance.enableYaw) + { + yawPosActuation = GetGimbalWorldRotation(__instance, vesselReferenceTransform, gimbalTransform, Vector3.forward, controlPoint, inversedControl, neutralLocalRot, neutralWorldRot); + yawNegActuation = GetGimbalWorldRotation(__instance, vesselReferenceTransform, gimbalTransform, Vector3.back, controlPoint, inversedControl, neutralLocalRot, neutralWorldRot); + } + else + { + yawPosActuation = Quaternion.identity; + yawNegActuation = Quaternion.identity; + } + + int engineIndex = engines.Count; + while (engineIndex-- > 0) + { + KeyValuePair engineThrustMultiplier = engines[engineIndex]; + + ModuleEngines engine = engineThrustMultiplier.Key; + float thrustMultiplier = engineThrustMultiplier.Value; + + float thrustMagnitude; + if (isEditor) + { + if (editorPhysics.atmStaticPressure == 0f) + thrustMagnitude = engine.MaxThrustOutputVac(true); + else + thrustMagnitude = engine.MaxThrustOutputAtm(true, true, (float)editorPhysics.atmStaticPressure, editorPhysics.atmTemperature, editorPhysics.atmDensity); + } + else + { + thrustMagnitude = engine.finalThrust; + } + + thrustMagnitude *= thrustMultiplier; + + if (thrustMagnitude <= 0f) + continue; + + int thrustTransformIndex = engine.thrustTransforms.Count; + while (thrustTransformIndex-- > 0) + { + Transform thrustTransform = engine.thrustTransforms[thrustTransformIndex]; + + // To get the "neutral" transform position, we need to walk back the transform hierarchy to correct for the current gimbal + // rotation induced thrustTransform position offset. It's not critical to do it (see below as for why), but it would be weird + // to have the end results varying slightly depending on the current actuation. + // But note that when getting the actuated forces, we don't use the modified thrustTransform position. In most cases, the + // actuation induced position shift of the thrustTransform won't matter much, since the gimbal pivot - thrustTransform distance + // is usally tiny compared to the CoM-thrustTransform distance. + Vector3 thrustTransformPosition = gimbalTransform.position + (gimbalWorldRotToNeutral * (thrustTransform.position - gimbalTransform.position)); + Vector3 trustPosFromCoM = thrustTransformPosition - worldCoM; + + // get the neutral thrust force by removing the thrustTransform current actuation induced rotation + Vector3 neutralThrustForce = gimbalWorldRotToNeutral * (thrustTransform.forward * thrustMagnitude); + + // get the "natural" torque induced by the engine thrust, in world space + neutralTorque += Vector3.Cross(trustPosFromCoM, neutralThrustForce); + + if (__instance.enablePitch) + { + pitchPosTorque += Vector3.Cross(trustPosFromCoM, pitchPosActuation * neutralThrustForce); + pitchNegTorque += Vector3.Cross(trustPosFromCoM, pitchNegActuation * neutralThrustForce); + } + + if (__instance.enableRoll) + { + rollPosTorque += Vector3.Cross(trustPosFromCoM, rollPosActuation * neutralThrustForce); + rollNegTorque += Vector3.Cross(trustPosFromCoM, rollNegActuation * neutralThrustForce); + } + + if (__instance.enableYaw) + { + yawPosTorque += Vector3.Cross(trustPosFromCoM, yawPosActuation * neutralThrustForce); + yawNegTorque += Vector3.Cross(trustPosFromCoM, yawNegActuation * neutralThrustForce); + } + } + } + + neutralTorque = vesselReferenceTransform.InverseTransformDirection(neutralTorque); + + if (__instance.enablePitch) + { + pitchPosTorque = vesselReferenceTransform.InverseTransformDirection(pitchPosTorque); + pitchNegTorque = vesselReferenceTransform.InverseTransformDirection(pitchNegTorque); + pos.x += pitchPosTorque.x - neutralTorque.x; + neg.x -= pitchNegTorque.x - neutralTorque.x; + } + + if (__instance.enableRoll) + { + rollPosTorque = vesselReferenceTransform.InverseTransformDirection(rollPosTorque); + rollNegTorque = vesselReferenceTransform.InverseTransformDirection(rollNegTorque); + pos.y += rollPosTorque.y - neutralTorque.y; + neg.y -= rollNegTorque.y - neutralTorque.y; + } + + if (__instance.enableYaw) + { + yawPosTorque = vesselReferenceTransform.InverseTransformDirection(yawPosTorque); + yawNegTorque = vesselReferenceTransform.InverseTransformDirection(yawNegTorque); + pos.z += yawPosTorque.z - neutralTorque.z; + neg.z -= yawNegTorque.z - neutralTorque.z; + } + } + + gimbalCache.pos = pos; + gimbalCache.neg = neg; + + gimbalProfiler.End(); +#if DEBUG + TorqueUIModule ui = __instance.part.FindModuleImplementing(); + if (ui != null) + { + ui.pos = pos; + ui.neg = neg; + } +#endif + return false; + } + + static Quaternion GetGimbalWorldRotation(ModuleGimbal mg, Transform referenceTransform, Transform gimbalTransform, Vector3 ctrlState, Vector3 controlPoint, bool inversedControl, Quaternion neutralLocalRot, Quaternion neutralWorldRot) + { + if (inversedControl) + { + ctrlState.x *= -1f; + ctrlState.z *= -1f; + } + + if (ctrlState.y != 0f && mg.enableRoll) + { + if (controlPoint.x > mg.minRollOffset) + ctrlState.x += ctrlState.y; + else if (controlPoint.x < -mg.minRollOffset) + ctrlState.x -= ctrlState.y; + + if (controlPoint.z > mg.minRollOffset) + ctrlState.z += ctrlState.y; + else if (controlPoint.z < -mg.minRollOffset) + ctrlState.z -= ctrlState.y; + } + + // Stock does gimbalTransform.InverseTransformDirection(), resulting in the available torque varying with the current gimbal actuation... + // To work around that, we call InverseTransformDirection() on the parent, then apply the neutral rotation. + Vector3 localActuation = + Quaternion.Inverse(neutralLocalRot) + * gimbalTransform.parent.InverseTransformDirection(referenceTransform.TransformDirection(ctrlState)); + + // get actuation angles + localActuation.x = Mathf.Clamp(localActuation.x, -1f, 1f) * ((localActuation.x > 0f) ? mg.gimbalRangeXP : mg.gimbalRangeXN) * mg.gimbalLimiter * 0.01f; + localActuation.y = Mathf.Clamp(localActuation.y, -1f, 1f) * ((localActuation.y > 0f) ? mg.gimbalRangeYP : mg.gimbalRangeYN) * mg.gimbalLimiter * 0.01f; + + // get local rotation + Quaternion gimbalRotation = + neutralLocalRot + * Quaternion.AngleAxis(localActuation.x, mg.xMult * Vector3.right) + * Quaternion.AngleAxis(localActuation.y, mg.yMult * (mg.flipYZ ? Vector3.forward : Vector3.up)); + + // transform in world space + gimbalRotation = (gimbalTransform.parent.rotation * gimbalRotation) * Quaternion.Inverse(neutralWorldRot); + + return gimbalRotation; + } + +#endregion + } + +#if DEBUG + public class TorqueUIModule : PartModule + { + [KSPField(guiActive = true, guiFormat = "F1")] + public Vector3 pos; + [KSPField(guiActive = true, guiFormat = "F1")] + public Vector3 neg; + + // control surface debug stuff + [KSPField(guiActive = false, guiFormat = "F1")] + public Vector3 spos; + [KSPField(guiActive = false, guiFormat = "F1")] + public Vector3 sneg; + [KSPField(guiActive = false, guiFormat = "F1")] + public Vector3 posAction; + [KSPField(guiActive = false, guiFormat = "F1")] + public Vector3 negAction; + [KSPField(guiActive = false, guiFormat = "F1")] + public Vector3 actionV; + + // gimbal debug stuff + [KSPField(guiActive = false, guiFormat = "F1")] + public Vector3 gimbalNeutralTorque; + } +#endif +} diff --git a/KSPCommunityFixes/BugFixes/ReactionWheelsPotentialTorque.cs b/KSPCommunityFixes/BugFixes/ReactionWheelsPotentialTorque.cs deleted file mode 100644 index 85ddcb2..0000000 --- a/KSPCommunityFixes/BugFixes/ReactionWheelsPotentialTorque.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System; -using HarmonyLib; -using System.Collections.Generic; -using UnityEngine; -using static ModuleReactionWheel; - -namespace KSPCommunityFixes -{ - public class ReactionWheelsPotentialTorque : BasePatch - { - protected override Version VersionMin => new Version(1, 8, 0); - - protected override void ApplyPatches(List patches) - { - patches.Add(new PatchInfo( - PatchMethodType.Prefix, - AccessTools.Method(typeof(ModuleReactionWheel), nameof(ModuleReactionWheel.GetPotentialTorque)), - this)); - } - - static bool ModuleReactionWheel_GetPotentialTorque_Prefix(ModuleReactionWheel __instance, out Vector3 pos, out Vector3 neg) - { - if (__instance.moduleIsEnabled && __instance.wheelState == WheelState.Active && __instance.actuatorModeCycle != 2) - { - float authorityLimiter = __instance.authorityLimiter * 0.01f; - neg.x = (pos.x = __instance.PitchTorque * authorityLimiter); - neg.y = (pos.y = __instance.RollTorque * authorityLimiter); - neg.z = (pos.z = __instance.YawTorque * authorityLimiter); - return false; - } - - pos = (neg = Vector3.zero); - return false; - } - } -} diff --git a/KSPCommunityFixes/Internal/EditorPhysics.cs b/KSPCommunityFixes/Internal/EditorPhysics.cs new file mode 100644 index 0000000..29b6e48 --- /dev/null +++ b/KSPCommunityFixes/Internal/EditorPhysics.cs @@ -0,0 +1,214 @@ +using System; +using System.Collections.Generic; +using HarmonyLib; +using UnityEngine; + +namespace KSPCommunityFixes +{ + public class EditorPhysics : BasePatch + { + protected override bool IgnoreConfig => true; + + protected override Version VersionMin => new Version(1, 12, 3); + + protected override void ApplyPatches(List patches) + { + instance = this; + + patches.Add(new PatchInfo( + PatchMethodType.Postfix, + AccessTools.Method(typeof(DeltaVAppSituation), nameof(DeltaVAppSituation.UpdatePressureDisplay)), + this)); + + GameEvents.onEditorShipModified.Add(OnEditorShipModified); + GameEvents.onDeltaVAppAtmosphereChanged.Add(OnDeltaVAppAtmosphereChanged); + GameEvents.OnControlPointChanged.Add(OnControlPointChanged); + GameEvents.onGameSceneLoadRequested.Add(OnGameSceneLoadRequested); + } + + private static EditorPhysics instance; + public static bool TryGetAndUpdate(out EditorPhysics updatedInstance) + { + instance.Update(); + if (instance.isValid) + { + updatedInstance = instance; + return true; + } + + updatedInstance = null; + return false; + } + + private bool isValid; + + public Vector3 CoM => EditorMarker_CoM.CraftCoM; + public Transform referenceTransform; + public Part referencePart; + public int referencePartShipIndex; + + public CelestialBody body; + public double atmStaticPressureKpa; + public double atmStaticPressure; + public double atmDensity; + public double atmTemperature; + + public int lastShipModificationFrame = int.MaxValue; + private int lastShipStatsUpdateFrame; + + private void Update() + { + if (lastShipStatsUpdateFrame < lastShipModificationFrame) + { + lastShipStatsUpdateFrame = lastShipModificationFrame; + EditorUpdateShipStats(); + } + } + + private void EditorUpdateShipStats() + { + if (EditorLogic.fetch.ship == null || EditorLogic.fetch.ship.parts.Count == 0) + { + isValid = false; + return; + } + + if (DeltaVGlobals.fetch != null && DeltaVGlobals.ready && DeltaVGlobals.DeltaVAppValues.body != null) + { + body = DeltaVGlobals.DeltaVAppValues.body; + + if (!DeltaVGlobals.DeltaVAppValues.body.atmosphere || DeltaVGlobals.DeltaVAppValues.situation == DeltaVSituationOptions.Vaccum) + { + + atmStaticPressureKpa = 0.0; + atmStaticPressure = 0.0; + atmTemperature = 0.0; + atmDensity = 0.0; + } + else + { + if (DeltaVGlobals.DeltaVAppValues.situation == DeltaVSituationOptions.Altitude) + { + atmTemperature = body.GetFullTemperature(DeltaVGlobals.DeltaVAppValues.altitude, 0.0); + atmStaticPressureKpa = body.GetPressure(DeltaVGlobals.DeltaVAppValues.altitude); + } + else + { + atmTemperature = body.GetFullTemperature(0.0, 0.0); + atmStaticPressureKpa = body.GetPressure(0.0); + } + + atmStaticPressure = atmStaticPressureKpa * 0.0098692326671601278; + atmDensity = DeltaVGlobals.DeltaVAppValues.atmDensity; + } + } + else + { + atmStaticPressureKpa = 0.0; + atmStaticPressure = 0.0; + atmTemperature = 0.0; + atmDensity = 0.0; + } + + if (referenceTransform.IsNullOrDestroyed() || EditorLogic.fetch.ship.Parts.IndexOf(referencePart) != referencePartShipIndex) + { + if (!GetFirstReferenceTransform(EditorLogic.RootPart, ref referenceTransform, ref referencePart)) + { + referencePart = EditorLogic.RootPart; + referenceTransform = EditorLogic.RootPart.referenceTransform; + } + + referencePartShipIndex = EditorLogic.fetch.ship.Parts.IndexOf(referencePart); + } + + if (referenceTransform.IsNullOrDestroyed()) + { + isValid = false; + return; + } + + bool GetFirstReferenceTransform(Part part, ref Transform referenceTransform, ref Part referencePart) + { + if (part.isControlSource != Vessel.ControlLevel.NONE) + { + ModuleCommand mc = part.FindModuleImplementing(); + if (mc != null && mc.controlPoints != null && mc.controlPoints.TryGetValue(mc.activeControlPointName, out ControlPoint ctrlPoint)) + referenceTransform = ctrlPoint.transform; + else + referenceTransform = part.referenceTransform; + + referencePart = part; + return true; + } + + int childIdx = part.children.Count; + while (childIdx-- > 0) + { + if (GetFirstReferenceTransform(part.children[childIdx], ref referenceTransform, ref referencePart)) + return true; + } + + return false; + } + + EditorMarker_CoM comMarker = EditorVesselOverlays.fetch.CoMmarker; + if (comMarker == null) + { + isValid = false; + return; + } + + if (!comMarker.isActiveAndEnabled) + { + comMarker.rootPart = EditorLogic.RootPart; + comMarker.UpdatePosition(); + } + + isValid = true; + } + + private void OnGameSceneLoadRequested(GameScenes data) + { + referenceTransform = null; + referencePart = null; + referencePartShipIndex = -1; + atmStaticPressure = 0f; + lastShipModificationFrame = 0; + lastShipStatsUpdateFrame = 0; + } + + private void OnControlPointChanged(Part part, ControlPoint controlPoint) + { + if (HighLogic.LoadedScene != GameScenes.EDITOR || EditorLogic.fetch.ship == null) + return; + + int partIndex = EditorLogic.fetch.ship.Parts.IndexOf(part); + if (partIndex < 0) + return; + + lastShipModificationFrame = Time.frameCount; + referenceTransform = controlPoint.transform; + referencePart = part; + referencePartShipIndex = partIndex; + } + + private void OnDeltaVAppAtmosphereChanged(DeltaVSituationOptions data) + { + lastShipModificationFrame = Time.frameCount; + } + + private void OnEditorShipModified(ShipConstruct ship) + { + if (ship != EditorLogic.fetch.ship || ship.parts.Count == 0) + return; + + lastShipModificationFrame = Time.frameCount; + } + + // OnDeltaVAppAtmosphereChanged isn't fired when the altitude is modified, so implement our own event + static void DeltaVAppSituation_UpdatePressureDisplay_Postfix() + { + instance.lastShipModificationFrame = Time.frameCount; + } + } +} \ No newline at end of file diff --git a/KSPCommunityFixes/KSPCommunityFixes.csproj b/KSPCommunityFixes/KSPCommunityFixes.csproj index d95b0c5..5045f8d 100644 --- a/KSPCommunityFixes/KSPCommunityFixes.csproj +++ b/KSPCommunityFixes/KSPCommunityFixes.csproj @@ -37,6 +37,15 @@ 4 true + + portable + true + bin\Profiling\ + TRACE;ENABLE_PROFILER + prompt + 4 + true + @@ -99,14 +108,17 @@ + + + @@ -131,7 +143,6 @@ - @@ -143,10 +154,13 @@ + + + diff --git a/KSPCommunityFixes/Modding/BaseFieldListUseFieldHost.cs b/KSPCommunityFixes/Modding/BaseFieldListUseFieldHost.cs new file mode 100644 index 0000000..171d05e --- /dev/null +++ b/KSPCommunityFixes/Modding/BaseFieldListUseFieldHost.cs @@ -0,0 +1,210 @@ +using HarmonyLib; +using System; +using System.Collections.Generic; +using System.Reflection; + +/* +The purpose of this patch if to allow BaseField and associated features (PAW controls, persistence, etc) to work when +a custom BaseField is added to a BaseFieldList (ie, a Part or PartModule) with a host instance other than the BaseFieldList +owner. This allow to dynamically add fields defined in another class to a Part or PartModule and to benefit from all the +associated KSP sugar : +- PAW UI controls +- Value and symmetry events +- Automatic persistence on the Part/PartModule hosting the BaseFieldList + +The whole thing seems actually designed with such a scenario in mind, but for some reason some BaseField and BaseFieldList +methods are using the BaseFieldList.host instance instead of the BaseField.host instance (as for why BaseFieldList has a +"host" at all, I've no idea and this seems to be a design oversight). There is little to no consistency in which host +reference is used, they are even sometimes mixed in the same method. For example, BaseFieldList.Load() uses BaseFieldList.host +in its main body, then calls BaseFieldList.SetOriginalValue() which is relying on BaseField.host. + +Changing every place where a `host` reference is acquired to ensure the BaseField.host reference is used allow to use a custom +host instance, and shouldn't result in any behavior change. This being said, the stock code can theoretically allow a plugin +to instantiate a custom BaseField with a null host and have it kinda functional if that field is only used to SetValue() / +Getvalue() and as long as the field isn't persistent and doesn't have any associated UI_Control. This feels like an extremly +improbable scenario, so this is probably fine. +*/ + +namespace KSPCommunityFixes.Modding +{ + [PatchPriority(Order = 0)] + class BaseFieldListUseFieldHost : BasePatch + { + private static AccessTools.FieldRef> BaseField_OnValueModified_FieldRef; + + protected override Version VersionMin => new Version(1, 12, 3); + + protected override void ApplyPatches(List patches) + { + + BaseField_OnValueModified_FieldRef = AccessTools.FieldRefAccess>(typeof(BaseField), nameof(BaseField.OnValueModified)); + if (BaseField_OnValueModified_FieldRef == null) + throw new MissingFieldException($"BaseFieldListUseFieldHost patch could not find the BaseField.OnValueModified event backing field"); + + MethodInfo BaseField_GetValue_MethodInfo = null; + foreach (MethodInfo declaredMethod in AccessTools.GetDeclaredMethods(typeof(BaseField))) + { + if (declaredMethod.Name == nameof(BaseField.GetValue) && !declaredMethod.IsGenericMethod) + { + BaseField_GetValue_MethodInfo = declaredMethod; + break; + } + } + if (BaseField_GetValue_MethodInfo == null) + throw new MissingMethodException($"BaseFieldListUseFieldHost patch could not find the BaseField.GetValue() method"); + + // Note : fortunately, we don't need to patch the generic BaseField.GetValue(object host) method because it calls + // the non-generic GetValue method + patches.Add(new PatchInfo( + PatchMethodType.Prefix, + BaseField_GetValue_MethodInfo, + this, nameof(BaseField_GetValue_Prefix))); + + patches.Add(new PatchInfo( + PatchMethodType.Prefix, + AccessTools.Method(typeof(BaseField), nameof(BaseField.SetValue)), + this, nameof(BaseField_SetValue_Prefix))); + + + // BaseField.Read() is a public method called from : + // - BaseFieldList.Load() + // - BaseFieldList.ReadValue() (2 overloads) + // The method is really tiny so there is a potential inlining risk (doesn't happen in my tests, but this stuff can be platform + // dependent). It's only really critical to have BaseFieldList.Load() being patched, the ReadValue() methods are unused in the + // stock codebase, and it is doubtfull anybody would ever call them. + patches.Add(new PatchInfo( + PatchMethodType.Prefix, + AccessTools.Method(typeof(BaseField), nameof(BaseField.Read)), + this)); + + // We also patch BaseFieldList.Load() because : + // - Of the above mentioned inlining risk + // - Because it pass the (arguably wrong) host reference to the UI_Control.Load() method, and even though none + // of the various overloads make use of that argument we might want to be consistent. This being said, in the context + // of a UI_Control, it might be expected that the "host" should be the host of the control (ie, a Part or PartModule), + // and not the host of the backing BaseField. In the end, it is doubtful anybody as ever relied on this unused-in-stock + // unclear-what-this-is stuff. Not to mention I'm not even sure anybody has ever defined a custom UI_Control (B9PS maybe ?), + // since this isn't something that can be done out of the box without some major hacking around. + patches.Add(new PatchInfo( + PatchMethodType.Prefix, + AccessTools.Method(typeof(BaseFieldList), nameof(BaseFieldList.Load)), + this)); + } + + static bool BaseField_GetValue_Prefix(BaseField __instance, out object __result) + { + try + { + // uses the field host reference instead of the reference passed as a parameter + // in case the field host reference is null, let the call throw and call the original method + // In theory, this should never happen unless a plugin is doing very sketchy like instantiating + // a BaseField manually with a null host. + __result = __instance._fieldInfo.GetValue(__instance._host); + return false; + } + catch + { + __result = null; + return true; + } + } + + static bool BaseField_SetValue_Prefix(BaseField __instance, object newValue, out bool __result) + { + try + { + __instance._fieldInfo.SetValue(__instance._host, newValue); + + // Note : since BaseField.OnValueModified is a "field-like event", it is relying on a compiler-generated + // private backing field, and the public event "synctatic suger" member can only be invoked from the declaring + // class, so we can't directly call "__instance.OnValueModified()" here. Additionally, the compiler has the extremly + // bad taste to name the backing field "OnValueModified" too, resulting in an "ambiguous reference" if we try to use + // it through the publicized assembly. So we have to resort to creating a FieldRef open delegate for that backing field. + BaseField_OnValueModified_FieldRef(__instance)?.Invoke(newValue); + __result = true; + return false; + } + catch + { + __result = false; + return true; + } + } + + static bool BaseField_Read_Prefix(BaseField __instance, string value) + { + BaseField.ReadPvt(__instance._fieldInfo, value, __instance._host); + return false; + } + + static bool BaseFieldList_Load_Prefix(BaseFieldList __instance, ConfigNode node) + { + for (int i = 0; i < node.values.Count; i++) + { + ConfigNode.Value value = node.values[i]; + BaseField baseField = __instance[value.name]; + if (baseField == null || baseField.hasInterface || baseField.uiControlOnly) + { + continue; + } + + // The original code calls BaseField.Read() here. We bypass it to avoid + // any inlining risk and call directly the underlying static method. + BaseField.ReadPvt(baseField._fieldInfo, value.value, baseField._host); + + if (baseField.uiControlFlight.GetType() != typeof(UI_Label)) + { + ConfigNode node2 = node.GetNode(value.name + "_UIFlight"); + if (node2 != null) + { + baseField.uiControlFlight.Load(node2, baseField._host); + } + } + else if (baseField.uiControlEditor.GetType() != typeof(UI_Label)) + { + ConfigNode node3 = node.GetNode(value.name + "_UIEditor"); + if (node3 != null) + { + baseField.uiControlEditor.Load(node3, baseField._host); + } + } + } + for (int j = 0; j < node.nodes.Count; j++) + { + ConfigNode configNode = node.nodes[j]; + BaseField baseField2 = __instance[configNode.name]; + if (baseField2 == null || !baseField2.hasInterface || baseField2.uiControlOnly) + { + continue; + } + + object value2 = baseField2.GetValue(baseField2._host); + if (value2 == null) + { + continue; + } + (value2 as IConfigNode)?.Load(configNode); + if (baseField2.uiControlFlight.GetType() != typeof(UI_Label)) + { + ConfigNode node4 = node.GetNode(configNode.name + "_UIFlight"); + if (node4 != null) + { + baseField2.uiControlFlight.Load(node4, baseField2._host); + } + } + else if (baseField2.uiControlEditor.GetType() != typeof(UI_Label)) + { + ConfigNode node5 = node.GetNode(configNode.name + "_UIEditor"); + if (node5 != null) + { + baseField2.uiControlEditor.Load(node5, baseField2._host); + } + } + } + __instance.SetOriginalValue(); + return false; + } + + + } +} diff --git a/KSPCommunityFixes/Performance/NoLiftInSpace.cs b/KSPCommunityFixes/Performance/NoLiftInSpace.cs new file mode 100644 index 0000000..74cc7a8 --- /dev/null +++ b/KSPCommunityFixes/Performance/NoLiftInSpace.cs @@ -0,0 +1,104 @@ +using System; +using HarmonyLib; +using System.Collections.Generic; +using UnityEngine; + +namespace KSPCommunityFixes.Performance +{ + class NoLiftInSpace : BasePatch + { + protected override Version VersionMin => new Version(1, 12, 3); + + protected override void ApplyPatches(List patches) + { + patches.Add(new PatchInfo( + PatchMethodType.Prefix, + AccessTools.Method(typeof(ModuleLiftingSurface), nameof(ModuleLiftingSurface.FixedUpdate)), + this)); + + patches.Add(new PatchInfo( + PatchMethodType.Prefix, + AccessTools.Method(typeof(ModuleControlSurface), nameof(ModuleControlSurface.FixedUpdate)), + this)); + } + + static bool ModuleLiftingSurface_FixedUpdate_Prefix(ModuleLiftingSurface __instance) + { + if (HighLogic.LoadedSceneIsFlight && __instance.part.dynamicPressurekPa == 0.0 && __instance.part.submergedDynamicPressurekPa == 0.0) + { + if (__instance.pointVelocity != Vector3.zero) + { + __instance.pointVelocity = Vector3.zero; + __instance.nVel = Vector3.zero; + __instance.liftVector = Vector3.zero; + __instance.liftForce = Vector3.zero; + __instance.dragForce = Vector3.zero; + __instance.Qdrag = 0.0; + __instance.Qlift = 0.0; + __instance.liftDot = 0f; + __instance.liftField.guiActive = false; + __instance.dragField.guiActive = false; + } + return false; + } + + return true; + } + + static bool ModuleControlSurface_FixedUpdate_Prefix(ModuleControlSurface __instance) + { + if (HighLogic.LoadedSceneIsFlight && __instance.part.dynamicPressurekPa == 0.0 && __instance.part.submergedDynamicPressurekPa == 0.0) + { + if (__instance.pointVelocity != Vector3.zero) + { + __instance.pointVelocity = Vector3.zero; + __instance.nVel = Vector3.zero; + __instance.liftVector = Vector3.zero; + __instance.liftForce = Vector3.zero; + __instance.dragForce = Vector3.zero; + __instance.Qdrag = 0.0; + __instance.Qlift = 0.0; + __instance.liftDot = 0f; + __instance.liftField.guiActive = false; + __instance.dragField.guiActive = false; + __instance.baseLiftForce = Vector3.zero; + } + + if (__instance.deploy && __instance.currentDeployAngle != __instance.deployAngle) + { + float sign = __instance.usesMirrorDeploy + ? ((__instance.deployInvert ? (-1f) : 1f) * (__instance.partDeployInvert ? (-1f) : 1f) * (__instance.mirrorDeploy ? (-1f) : 1f)) + : ((__instance.deployInvert ? (-1f) : 1f) * Mathf.Sign((Quaternion.Inverse(__instance.vessel.ReferenceTransform.rotation) * (__instance.baseTransform.position - __instance.vessel.CurrentCoM)).x)); + __instance.currentDeployAngle = -1f * sign * __instance.deployAngle; + } + + if (__instance.ctrlSurface != null && __instance.deploy ? __instance.deflection != __instance.currentDeployAngle : __instance.deflection != 0f) + { + float targetAngle = __instance.deploy ? __instance.currentDeployAngle : 0f; + __instance.deflection = Mathf.MoveTowards(__instance.deflection, targetAngle, __instance.actuatorSpeed * TimeWarp.fixedDeltaTime); + __instance.ctrlSurface.localRotation = Quaternion.AngleAxis(__instance.deflection, Vector3.right) * __instance.neutral; + + if (__instance.deflection == targetAngle) + { + __instance.inputVector = Vector3.zero; + __instance.action = 0f; + __instance.roll = 0f; + + if (__instance.displaceVelocity) + { + __instance.PitchCtrlState = "n/a"; + __instance.RollCtrlState = "n/a"; + __instance.YawCtrlState = "n/a"; + __instance.potentialBladeControlTorque = Vector3.zero; + __instance.rotatingControlInput = Vector3.zero; + } + } + } + + return false; + } + + return true; + } + } +} diff --git a/KSPCommunityFixes/QoL/BetterSAS.cs b/KSPCommunityFixes/QoL/BetterSAS.cs new file mode 100644 index 0000000..99a7e1d --- /dev/null +++ b/KSPCommunityFixes/QoL/BetterSAS.cs @@ -0,0 +1,824 @@ +using HarmonyLib; +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using Unity.Profiling; +using UnityEngine; + +namespace KSPCommunityFixes.QoL +{ + class BetterSAS : BasePatch + { + protected override Version VersionMin => new Version(1, 12, 3); + + protected override void ApplyPatches(List patches) + { + patches.Add(new PatchInfo( + PatchMethodType.Prefix, + AccessTools.Constructor(typeof(VesselAutopilot), new Type[]{typeof(Vessel)}), + this, nameof(VesselAutopilot_Ctor_Prefix))); + + patches.Add(new PatchInfo( + PatchMethodType.Prefix, + AccessTools.Method(typeof(VesselAutopilot.VesselSAS), nameof(VesselAutopilot.VesselSAS.ConnectFlyByWire)), + this, nameof(VesselAutopilot_VesselSAS_ConnectFlyByWire_Prefix))); + + patches.Add(new PatchInfo( + PatchMethodType.Prefix, + AccessTools.Method(typeof(VesselAutopilot.VesselSAS), nameof(VesselAutopilot.VesselSAS.DisconnectFlyByWire)), + this, nameof(VesselAutopilot_VesselSAS_DisconnectFlyByWire_Prefix))); + + patches.Add(new PatchInfo( + PatchMethodType.Prefix, + AccessTools.Method(typeof(VesselAutopilot.VesselSAS), nameof(VesselAutopilot.VesselSAS.ResetAllPIDS)), + this, nameof(VesselAutopilot_VesselSAS_ResetAllPIDS_Prefix))); + + patches.Add(new PatchInfo( + PatchMethodType.Postfix, + AccessTools.Method(typeof(ModuleCommand), nameof(ModuleCommand.OnLoad)), + this)); + + patches.Add(new PatchInfo( + PatchMethodType.Postfix, + AccessTools.Method(typeof(ModuleCommand), nameof(ModuleCommand.OnSave)), + this)); + + patches.Add(new PatchInfo( + PatchMethodType.Postfix, + AccessTools.Method(typeof(ModuleCommand), nameof(ModuleCommand.OnStart)), + this)); + + GameEvents.onVesselsUndocking.Add(OnUndockOrDecouple); + GameEvents.onPartDeCoupleNewVesselComplete.Add(OnUndockOrDecouple); + GameEvents.onPartCoupleComplete.Add(OnDockOrCouple); + } + + private void OnDockOrCouple(GameEvents.FromToAction data) + { + Part vesselPart = data.to; + Part dockingPart = data.from; + + if (!(vesselPart.vessel.autopilot.SAS is KSPCFVesselSAS vesselSAS)) + return; + + UpdateModuleCommandStateRecursive(dockingPart, vesselSAS.AttitudeControllerGuiName()); + + void UpdateModuleCommandStateRecursive(Part part, string eventGuiName) + { + ModuleCommand mc = part.FindModuleImplementing(); + if (!ReferenceEquals(mc, null)) + { + BaseEvent baseEvent = mc.Events[KSPCFVesselSAS.EVENT_HASHCODE]; + if (baseEvent != null) + baseEvent.guiName = eventGuiName; + } + + int childIdx = part.children.Count; + while (childIdx-- > 0) + UpdateModuleCommandStateRecursive(part.children[childIdx], eventGuiName); + } + } + + private void OnUndockOrDecouple(Vessel oldVessel, Vessel newVessel) + { + if (!(newVessel.autopilot.SAS is KSPCFVesselSAS newVesselSAS)) + return; + + if (!(oldVessel.autopilot.SAS is KSPCFVesselSAS oldVesselSAS)) + return; + + if (newVesselSAS.controller != oldVesselSAS.controller) + { + newVesselSAS.controller = oldVesselSAS.controller; + newVesselSAS.ResetAllPIDS(); + } + } + + static bool VesselAutopilot_Ctor_Prefix(VesselAutopilot __instance, Vessel vessel) + { + __instance.vessel = vessel; + __instance.sas = new KSPCFVesselSAS(vessel); + return false; + } + + static bool VesselAutopilot_VesselSAS_ConnectFlyByWire_Prefix(VesselAutopilot.VesselSAS __instance, bool reset) + { + if (!(__instance is KSPCFVesselSAS derivedInstance)) + return true; + + if (!derivedInstance.FBWconnected) + { + derivedInstance.vessel.OnAutopilotUpdate += new FlightInputCallback(derivedInstance.ControlUpdate); + + derivedInstance.FBWconnected = true; + if (!(derivedInstance.storedTransform != derivedInstance.vessel.ReferenceTransform) && !(derivedInstance.storedTransform == null)) + { + derivedInstance.LockRotation(derivedInstance.storedTransform.rotation); + } + else + { + derivedInstance.storedTransform = derivedInstance.vessel.ReferenceTransform; + derivedInstance.LockRotation(derivedInstance.storedTransform.rotation); + } + } + if (reset) + { + derivedInstance.ResetAllPIDS(); + } + + return false; + } + + static bool VesselAutopilot_VesselSAS_DisconnectFlyByWire_Prefix(VesselAutopilot.VesselSAS __instance) + { + if (!(__instance is KSPCFVesselSAS derivedInstance)) + return true; + + derivedInstance.targetOrientation = Vector3.zero; + derivedInstance.lockedRotation = Quaternion.identity; + derivedInstance.ResetAllPIDS(); + if (derivedInstance.FBWconnected) + { + derivedInstance.vessel.OnAutopilotUpdate -= new FlightInputCallback(derivedInstance.ControlUpdate); + derivedInstance.FBWconnected = false; + } + + return false; + } + + static bool VesselAutopilot_VesselSAS_ResetAllPIDS_Prefix(VesselAutopilot.VesselSAS __instance) + { + if (!(__instance is KSPCFVesselSAS derivedInstance)) + return true; + + derivedInstance.ResetAllPIDS(); + return false; + } + + private const string CONTROLLER_VALUENAME = "KSPCF_AttCtrl"; + + static void ModuleCommand_OnLoad_Postfix(ModuleCommand __instance, ConfigNode node) + { + if (__instance.vessel.DestroyedAsNull()?.autopilot?.sas == null || !(__instance.vessel.autopilot.sas is KSPCFVesselSAS customSAS)) + return; + + string controller = node.GetValue(CONTROLLER_VALUENAME); + if (!string.IsNullOrEmpty(controller) && Enum.TryParse(controller, out KSPCFVesselSAS.AttitudeController value)) + customSAS.controller = value; + } + + static void ModuleCommand_OnSave_Postfix(ModuleCommand __instance, ConfigNode node) + { + if (__instance.vessel.DestroyedAsNull()?.autopilot?.sas == null || !(__instance.vessel.autopilot.sas is KSPCFVesselSAS customSAS)) + return; + + node.AddValue(CONTROLLER_VALUENAME, customSAS.controller.ToString()); + } + + static KSPEvent attitudeControllerKSPEvent = new KSPEvent + { + advancedTweakable = true, + active = true, + guiActive = true, + guiActiveEditor = false + }; + + static void ModuleCommand_OnStart_Postfix(ModuleCommand __instance) + { + if (__instance.vessel.DestroyedAsNull()?.autopilot?.sas == null || !(__instance.vessel.autopilot.sas is KSPCFVesselSAS customSAS)) + return; + + BaseEventDelegate baseEventDelegate = () => KSPCFVesselSAS.OnAttitudeControllerSwitch(__instance); + BaseEvent baseEvent = __instance.events.Add(KSPCFVesselSAS.EVENT_NAME, baseEventDelegate, attitudeControllerKSPEvent); + baseEvent.guiName = customSAS.AttitudeControllerGuiName(); + } + } + + public class KSPCFVesselSAS : VesselAutopilot.VesselSAS + { + public enum AttitudeController + { + StockSAS = 1, + PreciseController = 2 + } + + public AttitudeController controller = AttitudeController.PreciseController; + + public KSPCFVesselSAS(Vessel v) : base(v) + { + } + + public new void ControlUpdate(FlightCtrlState s) + { + switch (controller) + { + case AttitudeController.StockSAS: + StockSASControlUpdate(s); + break; + case AttitudeController.PreciseController: + MechJebControlUpdate(s); + break; + } + } + + public new void ResetAllPIDS() + { + switch (controller) + { + case AttitudeController.StockSAS: + pidLockedPitch.Reset(); + pidLockedRoll.Reset(); + pidLockedYaw.Reset(); + break; + case AttitudeController.PreciseController: + MechJebResetResetPID(0); + MechJebResetResetPID(1); + MechJebResetResetPID(2); + break; + } + } + + private new void UpdateVesselTorque(FlightCtrlState s) + { + torqueVector = GetTotalVesselTorque(vessel); + } + + static ProfilerMarker vesselTorqueProfiler = new ProfilerMarker("KSPCFVesselSAS.GetTotalVesselTorque"); + + private new Vector3 GetTotalVesselTorque(Vessel v) + { + vesselTorqueProfiler.Begin(); + posTorque = Vector3.zero; + negTorque = Vector3.zero; + int partIdx = vessel.parts.Count; + while (partIdx-- > 0) + { + Part part = vessel.parts[partIdx]; + int moduleIdx = part.Modules.Count; + while (moduleIdx-- > 0) + { + PartModule pm = part.Modules[moduleIdx]; + if (!pm.IsDestroyed() && pm is ITorqueProvider torqueProvider) + { + torqueProvider.GetPotentialTorque(out Vector3 pos, out Vector3 neg); + posTorque += pos; + negTorque += neg; + } + } + } + + if (posTorque.x < 0f) posTorque.x = 0f; + if (posTorque.y < 0f) posTorque.y = 0f; + if (posTorque.z < 0f) posTorque.z = 0f; + if (negTorque.x < 0f) negTorque.x = 0f; + if (negTorque.y < 0f) negTorque.y = 0f; + if (negTorque.z < 0f) negTorque.z = 0f; + + Vector3 averageTorque = new Vector3( + 0.5f * (posTorque.x + negTorque.x), + 0.5f * (posTorque.y + negTorque.y), + 0.5f * (posTorque.z + negTorque.z)); + + vesselTorqueProfiler.End(); + return averageTorque; + } + + public const string EVENT_NAME = "KSPCFAttitudeControllerSwitch"; + public static readonly int EVENT_HASHCODE = EVENT_NAME.GetHashCode(); + + public static void OnAttitudeControllerSwitch(ModuleCommand origin) + { + if (origin.vessel.DestroyedAsNull()?.autopilot?.sas == null || !(origin.vessel.autopilot.sas is KSPCFVesselSAS customSAS)) + return; + + customSAS.ResetAllPIDS(); + + switch (customSAS.controller) + { + case AttitudeController.StockSAS: + customSAS.controller = AttitudeController.PreciseController; + break; + case AttitudeController.PreciseController: + customSAS.controller = AttitudeController.StockSAS; + break; + } + + string guiName = customSAS.AttitudeControllerGuiName(); + foreach (ModuleCommand moduleCommand in origin.vessel.FindPartModulesImplementing()) + { + BaseEvent baseEvent = moduleCommand.Events[EVENT_HASHCODE]; + if (baseEvent != null) + baseEvent.guiName = guiName; + } + + customSAS.ResetAllPIDS(); + } + + public string AttitudeControllerGuiName() + { + switch (controller) + { + case AttitudeController.PreciseController: + return "Attitude controller: PreciseController"; + case AttitudeController.StockSAS: + return "Attitude controller: Stock SAS"; + } + + return string.Empty; + } + + #region Stock SAS + + private Vector3 angularAccelerationPosMax; + private Vector3 angularAccelerationNegMax; + + private void StockSASControlUpdate(FlightCtrlState s) + { + if (!(storedTransform == null)) + { + UpdateVesselTorque(s); // called here instead from a separate OnAutopilotUpdate callback + + currentRotation = storedTransform.rotation; + UpdateMaximumAcceleration(); + rotationDelta = Quaternion.Inverse(GetRotationDelta()).eulerAngles; + if (!lockedMode) + { + PitchYawAngle(vessel.ReferenceTransform, targetOrientation, out neededPitch, out neededYaw); + rotationDelta.x = 0f - neededPitch; + rotationDelta.z = neededYaw; + CheckCoasting(); + } + else if (!dampingMode) + { + CheckCoasting(); + } + angularDelta.x = AngularTrim(rotationDelta.x); + angularDelta.y = AngularTrim(rotationDelta.y); + angularDelta.z = AngularTrim(rotationDelta.z); + angularDeltaRad = angularDelta * 0.01745329238474369; + StabilityDecay(); + AutoTuneScalar(); + sasResponse.x = pidLockedPitch.Update(angularDeltaRad.x, TimeWarp.deltaTime) / pidLockedPitch.clamp; + sasResponse.y = pidLockedRoll.Update(angularDeltaRad.y, TimeWarp.deltaTime) / pidLockedRoll.clamp; + sasResponse.z = pidLockedYaw.Update(angularDeltaRad.z, TimeWarp.deltaTime) / pidLockedYaw.clamp; + CheckDamping(); + sasResponse.x = UtilMath.Clamp(sasResponse.x, -1.0, 1.0); + sasResponse.y = UtilMath.Clamp(sasResponse.y, -1.0, 1.0); + sasResponse.z = UtilMath.Clamp(sasResponse.z, -1.0, 1.0); + s.pitch = (float)sasResponse.x; + s.roll = (float)sasResponse.y; + s.yaw = (float)sasResponse.z; + lastRotation = currentRotation; + } + } + + private new void UpdateMaximumAcceleration() + { + angularAccelerationMax.x = Mathf.Max(torqueVector.x / vessel.MOI.x, 0.0001f); + angularAccelerationMax.y = Mathf.Max(torqueVector.y / vessel.MOI.y, 0.0001f); + angularAccelerationMax.z = Mathf.Max(torqueVector.z / vessel.MOI.z, 0.0001f); + + angularAccelerationPosMax.x = Mathf.Max(posTorque.x / vessel.MOI.x, 0.0001f); + angularAccelerationPosMax.y = Mathf.Max(posTorque.y / vessel.MOI.y, 0.0001f); + angularAccelerationPosMax.z = Mathf.Max(posTorque.z / vessel.MOI.z, 0.0001f); + + angularAccelerationNegMax.x = Mathf.Max(negTorque.x / vessel.MOI.x, 0.0001f); + angularAccelerationNegMax.y = Mathf.Max(negTorque.y / vessel.MOI.y, 0.0001f); + angularAccelerationNegMax.z = Mathf.Max(negTorque.z / vessel.MOI.z, 0.0001f); + } + + private new void CheckCoasting() + { + if (angularAccelerationMax.x > 0f) + { + // float num = Mathf.Abs(Time.deltaTime + vessel.angularVelocity.x / angularAccelerationMax.x); + float num; + if (vessel.angularVelocity.x > 0f) + num = Mathf.Abs(Time.deltaTime + vessel.angularVelocity.x / angularAccelerationPosMax.x); + else + num = Mathf.Abs(Time.deltaTime + vessel.angularVelocity.x / angularAccelerationNegMax.x); + + + float num2 = Mathf.Abs(rotationDelta.x * ((float)Math.PI / 180f) / vessel.angularVelocity.x); + if (num * stopScalar > num2 && Math.Sign(rotationDelta.x) != Math.Sign(vessel.angularVelocity.x)) + { + pidLockedPitch.Reset(); + rotationDelta.x = 0f - rotationDelta.x; + } + else if (num * coastScalar > num2 && Math.Sign(rotationDelta.x) != Math.Sign(vessel.angularVelocity.x)) + { + pidLockedPitch.Reset(); + rotationDelta.x = 0f; + } + } + + if (angularAccelerationMax.z > 0f) + { + //float num3 = Mathf.Abs(Time.deltaTime + vessel.angularVelocity.z / angularAccelerationMax.z); + float num3; + if (vessel.angularVelocity.z > 0f) + num3 = Mathf.Abs(Time.deltaTime + vessel.angularVelocity.z / angularAccelerationPosMax.z); + else + num3 = Mathf.Abs(Time.deltaTime + vessel.angularVelocity.z / angularAccelerationNegMax.z); + + + float num4 = Mathf.Abs(rotationDelta.z * ((float)Math.PI / 180f) / vessel.angularVelocity.z); + if (num3 * stopScalar > num4 && Math.Sign(rotationDelta.z) != Math.Sign(vessel.angularVelocity.z)) + { + pidLockedYaw.Reset(); + rotationDelta.z = 0f - rotationDelta.z; + } + else if (num3 * coastScalar > num4 && Math.Sign(rotationDelta.z) != Math.Sign(vessel.angularVelocity.z)) + { + pidLockedYaw.Reset(); + rotationDelta.z = 0f; + } + } + } + + #endregion + + #region Mechjeb + + private class PIDLoop + { + public double Kp { get; set; } = 1.0; + public double Ki { get; set; } + public double Kd { get; set; } + public double Ts { get; set; } = 0.02; + public double N { get; set; } = 50; + public double B { get; set; } = 1; + public double C { get; set; } = 1; + public double SmoothIn { get; set; } = 1.0; + public double SmoothOut { get; set; } = 1.0; + public double MinOutput { get; set; } = double.MinValue; + public double MaxOutput { get; set; } = double.MaxValue; + + // internal state for PID filter + private double _d1, _d2; + + // internal state for last measured and last output for low pass filters + private double _m1 = double.NaN; + private double _o1 = double.NaN; + + public double Update(double reference, double measured) + { + // lowpass filter the input + measured = _m1.IsFiniteOrZero() ? _m1 + SmoothIn * (measured - _m1) : measured; + + double ep = B * reference - measured; + double ei = reference - measured; + double ed = C * reference - measured; + + // trapezoidal PID with derivative filtering as a digital biquad filter + double a0 = 2 * N * Ts + 4; + double a1 = -8 / a0; + double a2 = (-2 * N * Ts + 4) / a0; + double b0 = (4 * Kp * ep + 4 * Kd * ed * N + 2 * Ki * ei * Ts + 2 * Kp * ep * N * Ts + Ki * ei * N * Ts * Ts) / a0; + double b1 = (2 * Ki * ei * N * Ts * Ts - 8 * Kp * ep - 8 * Kd * ed * N) / a0; + double b2 = (4 * Kp * ep + 4 * Kd * ed * N - 2 * Ki * ei * Ts - 2 * Kp * ep * N * Ts + Ki * ei * N * Ts * Ts) / a0; + + // if we have NaN values saved into internal state that needs to be cleared here or it won't reset + if (!_d1.IsFiniteOrZero()) + _d1 = 0; + if (!_d2.IsFiniteOrZero()) + _d2 = 0; + + // transposed direct form 2 + double u0 = b0 + _d1; + u0 = AttitudeUtils.Clamp(u0, MinOutput, MaxOutput); + _d1 = b1 - a1 * u0 + _d2; + _d2 = b2 - a2 * u0; + + // low pass filter the output + _o1 = _o1.IsFiniteOrZero() ? _o1 + SmoothOut * (u0 - _o1) : u0; + + _m1 = measured; + + return _o1; + } + + public void Reset() + { + _d1 = _d2 = 0; + _m1 = double.NaN; + _o1 = double.NaN; + } + } + + private static readonly Vector3d _vector3dnan = new Vector3d(double.NaN, double.NaN, double.NaN); + + private const double EPS = 2.2204e-16; + + private readonly double VelKp = 9.18299345180006; + private readonly double VelKi = 16.2833478287224; + private readonly double VelKd = -0.0921320503942923; + private readonly double VelN = 99.6720838459594; + private readonly double VelB = 0.596313214751797; + private readonly double VelC = 0.596313214751797; + private readonly double VelSmoothIn = 0.3; + private readonly double VelSmoothOut = 1.0; + private readonly double PosSmoothIn = 1.0; + private readonly double PosFactor = 1.0; + private readonly double maxStoppingTime = 2.0; + private readonly double minFlipTime = 30; + private readonly double rollControlRange = 5.0; + private bool useControlRange = true; + private bool useFlipTime = true; + private bool useStoppingTime = true; + + /* error in pitch, roll, yaw */ + private Vector3d _error0 = Vector3d.zero; + private Vector3d _error1 = _vector3dnan; + + /* max angular acceleration */ + private Vector3d _maxAlpha = Vector3d.zero; + + /* max angular rotation */ + private Vector3d _maxOmega = Vector3d.zero; + private Vector3d _omega0 = _vector3dnan; + private Vector3d _targetOmega = Vector3d.zero; + private Vector3d _actuation = Vector3d.zero; + + /* error */ + private double _errorTotal; + + private readonly PIDLoop[] _pid = + { + new PIDLoop(), + new PIDLoop(), + new PIDLoop() + }; + + private Quaternion attitudeTarget; + + private void MechJebControlUpdate(FlightCtrlState st) + { + if (storedTransform == null) + return; + + UpdateAttitudeTarget(); + + UpdatePredictionPI(); + + for (int i = 0; i < 3; i++) + if (Math.Abs(_actuation[i]) < EPS || double.IsNaN(_actuation[i])) + _actuation[i] = 0; + + Vector3d act = _actuation; + + SetFlightCtrlState(act, st); + } + + private void UpdateAttitudeTarget() + { + currentRotation = storedTransform.rotation; + if (lockedMode) + { + attitudeTarget = lockedRotation; + } + else + { + Vector3 dir = targetOrientation; + Vector3 up = -vessel.GetTransform().forward; + Vector3.OrthoNormalize(ref dir, ref up); + attitudeTarget = Quaternion.LookRotation(dir, up); + } + } + + private void SetFlightCtrlState(Vector3d act, FlightCtrlState s) + { + bool userCommandingPitch = !Mathfx.Approx(s.pitch, s.pitchTrim, 0.1f); + bool userCommandingYaw = !Mathfx.Approx(s.yaw, s.yawTrim, 0.1f); + bool userCommandingRoll = !Mathfx.Approx(s.roll, s.rollTrim, 0.1f); + + if (userCommandingPitch) + MechJebResetResetPID(0); + + if (userCommandingRoll) + MechJebResetResetPID(1); + + if (userCommandingYaw) + MechJebResetResetPID(2); + + if (!userCommandingRoll) + if (!double.IsNaN(act.y)) + s.roll = Mathf.Clamp((float)act.y, -1f, 1f); + + if (!userCommandingPitch && !userCommandingYaw) + { + if (!double.IsNaN(act.x)) + s.pitch = Mathf.Clamp((float)act.x, -1f, 1f); + + if (!double.IsNaN(act.z)) + s.yaw = Mathf.Clamp((float)act.z, -1f, 1f); + } + } + + private void UpdateError() + { + Transform vesselTransform = vessel.ReferenceTransform; + + // 1. The Euler(-90) here is because the unity transform puts "up" as the pointy end, which is wrong. The rotation means that + // "forward" becomes the pointy end, and "up" and "right" correctly define e.g. AoA/pitch and AoS/yaw. This is just KSP being KSP. + // 2. We then use the inverse ship rotation to transform the requested attitude into the ship frame (we do everything in the ship frame + // first, and then negate the error to get the error in the target reference frame at the end). + Quaternion deltaRotation; + if (lockedMode) + deltaRotation = Quaternion.identity; + else + deltaRotation = Quaternion.Inverse(vesselTransform.transform.rotation * Quaternion.Euler(-90f, 0f, 0f)) * attitudeTarget; + + // get us some euler angles for the target transform + Vector3d ea = deltaRotation.eulerAngles; + double pitch = ea[0] * UtilMath.Deg2Rad; + double yaw = ea[1] * UtilMath.Deg2Rad; + double roll = ea[2] * UtilMath.Deg2Rad; + + // law of cosines for the "distance" of the miss in radians + _errorTotal = Math.Acos(AttitudeUtils.Clamp(Math.Cos(pitch) * Math.Cos(yaw), -1.0, 1.0)); + + // this is the initial direction of the great circle route of the requested transform + // (pitch is latitude, yaw is -longitude, and we are "navigating" from 0,0) + // doing this calculation is the ship frame is a bit easier to reason about. + var temp = new Vector3d(Math.Sin(pitch), Math.Cos(pitch) * Math.Sin(-yaw), 0.0); + temp = temp.normalized * _errorTotal; + + // we assemble phi in the pitch, roll, yaw basis that vessel.MOI uses (right handed basis) + var phi = new Vector3d( + AttitudeUtils.ClampRadiansPi(temp[0]), // pitch distance around the geodesic + AttitudeUtils.ClampRadiansPi(roll), + AttitudeUtils.ClampRadiansPi(temp[1]) // yaw distance around the geodesic + ); + + // apply the axis control from the parent controller + //phi.Scale(ac.AxisControl); + + // the error in the ship's position is the negative of the reference position in the ship frame + _error0 = -phi; + } + + private void UpdatePredictionPI() + { + GetTotalVesselTorque(vessel); + + _omega0 = vessel.angularVelocityD; + + UpdateError(); + + // lowpass filter on the error input + _error0 = _error1.IsFiniteOrZero() ? _error1 + PosSmoothIn * (_error0 - _error1) : _error0; + + double deltaT = TimeWarp.fixedDeltaTime; + + // needed to stop wiggling at higher phys warp + double warpFactor = Math.Pow(deltaT / 0.02, 0.90); // the power law here comes ultimately from the simulink PID tuning app + + // see https://archive.is/NqoUm and the "Alt Hold Controller", the acceleration PID is not implemented so we only + // have the first two PIDs in the cascade. + for (int i = 0; i < 3; i++) + { + double error = _error0[i]; + + double MOI = vessel.MOI[i]; + + // I don't think this is actually correct. The resulting actuation direction (and consequentely which torque value should + // be used) doesn't always match the error direction. As it is, I think this kinda work because a correct torque evaluation + // matter a lot more when decelerating than accelerating, but I this might be also be a source of unwanted oscillations + // when the error is small, as well as overshoots when the torque authority is very large (due to effective actuation when + // accelerating being higher than predicted). + // This being said, all that likely doesn't matter as much as the controller ignoring reaction delay for gimbals and + // control surfaces. I've no idea how to account for that, if that is at all possible in this controller design. + double availableTorque = error > 0.0 ? negTorque[i] : posTorque[i]; + + if (availableTorque != 0.0 && MOI != 0.0) + _maxAlpha[i] = availableTorque / MOI; + else + _maxAlpha[i] = 1.0; + + double maxAlphaCbrt = Math.Pow(_maxAlpha[i], 1.0 / 3.0); + double effLD = maxAlphaCbrt * PosFactor; + double posKp = Math.Sqrt(_maxAlpha[i] / (2.0 * effLD)); + + if (Math.Abs(error) <= 2.0 * effLD) + // linear ramp down of acceleration + _targetOmega[i] = -posKp * error; + else + // v = -sqrt(2 * F * x / m) is target stopping velocity based on distance + _targetOmega[i] = -Math.Sqrt(2 * _maxAlpha[i] * (Math.Abs(error) - effLD)) * Math.Sign(error); + + if (useStoppingTime) + { + _maxOmega[i] = _maxAlpha[i] * maxStoppingTime; + + if (useFlipTime) + _maxOmega[i] = Math.Max(_maxOmega[i], Math.PI / minFlipTime); + + _targetOmega[i] = AttitudeUtils.Clamp(_targetOmega[i], -_maxOmega[i], _maxOmega[i]); + } + + if (useControlRange && _errorTotal * Mathf.Rad2Deg > rollControlRange) + _targetOmega[1] = 0.0; + + _pid[i].Kp = VelKp / (_maxAlpha[i] * warpFactor); + _pid[i].Ki = VelKi / (_maxAlpha[i] * warpFactor * warpFactor); + _pid[i].Kd = VelKd / _maxAlpha[i]; + _pid[i].N = VelN / warpFactor; + _pid[i].B = VelB; + _pid[i].C = VelC; + _pid[i].Ts = deltaT; + _pid[i].SmoothIn = AttitudeUtils.Clamp01(VelSmoothIn); + _pid[i].SmoothOut = AttitudeUtils.Clamp01(VelSmoothOut); + _pid[i].MinOutput = -1; + _pid[i].MaxOutput = 1; + + // need the negative from the pid due to KSP's orientation of actuation + _actuation[i] = -_pid[i].Update(_targetOmega[i], _omega0[i]); + + if (Math.Abs(_actuation[i]) < EPS || double.IsNaN(_actuation[i])) + _actuation[i] = 0; + } + + _error1 = _error0; + } + + private void MechJebResetResetPID(int i) + { + _pid[i].Reset(); + _omega0[i] = _error0[i] = _error1[i] = double.NaN; + } + + #endregion + } + + public static class AttitudeUtils + { + /// Return false if the value equals NaN or Infinity, true otherwise + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static unsafe bool IsFiniteOrZero(this double value) + { + long doubleAsLong = BitConverter.DoubleToInt64Bits(value); + return (doubleAsLong & 0x7FFFFFFFFFFFFFFFL) < 9218868437227405312L; + } + + /// Return false if any component of the vector equals NaN or Infinity, true otherwise + public static bool IsFiniteOrZero(this Vector3d vector) + { + return vector.x.IsFiniteOrZero() && vector.y.IsFiniteOrZero() && vector.z.IsFiniteOrZero(); + } + + public static bool IsNaN(this Vector3 vector) + { +#pragma warning disable CS1718 // Comparison made to same variable + return vector.x != vector.x || vector.y != vector.y || vector.z != vector.z; +#pragma warning restore CS1718 // Comparison made to same variable + } + + public static Vector3 ClampComponents(this Vector3 v, Vector3 min, Vector3 max) => + new Vector3(Mathf.Clamp(v.x, min.x, max.x), + Mathf.Clamp(v.y, min.y, max.y), + Mathf.Clamp(v.z, min.z, max.z)); + + /// Clamp a value between min and max + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static double Clamp(double value, double min, double max) + { + if (min > max) + throw new ArgumentException($"{min} cannot be greater than {max}"); + + if (value < min) + return min; + else if (value > max) + return max; + + return value; + } + + /// Clamp a value between 0.0 and 1.0 + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static double Clamp01(double value) + { + if (value < 0.0) + return 0.0; + else if (value > 1.0) + return 1.0; + + return value; + } + + public static double ClampRadiansTwoPi(double angle) + { + angle = angle % (2.0 * Math.PI); + if (angle < 0) return angle + 2.0 * Math.PI; + else return angle; + } + + public static double ClampRadiansPi(double angle) + { + angle = ClampRadiansTwoPi(angle); + if (angle > Math.PI) angle -= 2.0 * Math.PI; + return angle; + } + + } +} diff --git a/KSPCommunityFixes/QoL/RCSLimiter.cs b/KSPCommunityFixes/QoL/RCSLimiter.cs new file mode 100644 index 0000000..5b0b8e5 --- /dev/null +++ b/KSPCommunityFixes/QoL/RCSLimiter.cs @@ -0,0 +1,690 @@ +/* +This patch implements two extra tweakables in the RCS module "Actuation Toggles" Part Action Window. + +By default, ModuleRCS will scale down the actuation level of each nozzle depending on how far the +thrust force is from the "ideal" angle for a given actuation request (unless the "always full action" +toggle is enabled). + +This patch gives the ability to define a separate angle threshold for linear and rotation actuation. +If the resulting angle from a nozzle thrust force is below that threshold, that nozzle won't fire at +all instead of firing at a reduced level. This allow to optimize efficiency, especially in the case +of multi-nozzle RCS parts that are impossible to fine-tune with only the actuation toggles. + +The default angle limits can be defined in the ModuleRCS / ModuleRCSFX configuration by adding +`minRotationAlignement` and `minlinearAlignement` fields (value in degrees). If they aren't defined, +they default to 90° (no limit, behavior similar to stock). + +To make RCS tweaking easier, the patch also add a potential torque/force readout to the actuation +toggles PAW items. In the editor, the actuation orientation is defined by the first found command +module, starting from the root part (matching the command module that will be selected as the control +point when launching the ship). The readout also takes the RCS module ISP curve into account, and +uses the currently selected body and state (sea level/altitude/vacuum) of the stock DeltaV app. + +The modification to the RCS control scheme is taken into account by the custom KSPCF +ModuleRCS.GetPotentialTorque() implementation. As of writing, all mods reimplement their own +version of that method, and all of them are ignoring the stock control scheme anyway, so the behavior +change introduced in this patch won't make a significant difference in most cases. +Note that RCSBuildAid tries to simulate the stock control scheme, but its implementation doesn't +reproduce stock behavior correctly, which is why its torque readout doesn't always match the KSPCF one. +*/ + +using System; +using System.Collections; +using HarmonyLib; +using System.Collections.Generic; +using System.Reflection; +using KSP.Localization; +using UnityEngine; +using KSPCommunityFixes.Modding; + +namespace KSPCommunityFixes.QoL +{ + [PatchPriority(Order = 10)] + class RCSLimiter : BasePatch + { + protected override Version VersionMin => new Version(1, 12, 3); + + protected override bool CanApplyPatch(out string reason) + { + if (KSPCommunityFixes.GetPatchInstance() == null) + { + reason = $"The {nameof(RCSLimiter)} patch requires the {nameof(BaseFieldListUseFieldHost)} to be enabled"; + return false; + } + return base.CanApplyPatch(out reason); + } + + protected override void ApplyPatches(List patches) + { + patches.Add(new PatchInfo( + PatchMethodType.Postfix, + AccessTools.Method(typeof(ModuleRCS), nameof(ModuleRCS.OnAwake)), + this)); + + patches.Add(new PatchInfo( + PatchMethodType.Postfix, + AccessTools.Method(typeof(ModuleRCS), nameof(ModuleRCS.OnDestroy)), + this)); + + patches.Add(new PatchInfo( + PatchMethodType.Postfix, + AccessTools.Method(typeof(ModuleRCS), nameof(ModuleRCS.UpdateToggles)), + this)); + + patches.Add(new PatchInfo( + PatchMethodType.Prefix, + AccessTools.Method(typeof(ModuleRCS), nameof(ModuleRCS.FixedUpdate)), + this)); + + patches.Add(new PatchInfo( + PatchMethodType.Postfix, + AccessTools.Method(typeof(ModuleRCS), nameof(ModuleRCS.Update)), + this)); + + moduleRCSExtensions = new Dictionary(); + + autoLOC_6001330_Pitch = Localizer.Format("#autoLOC_6001330"); + autoLOC_6001331_Yaw = Localizer.Format("#autoLOC_6001331"); + autoLOC_6001332_Roll = Localizer.Format("#autoLOC_6001332"); + + autoLOC_6001364_PortStbd = Localizer.Format("#autoLOC_6001364"); + string[] autoLOC_6001364 = autoLOC_6001364_PortStbd.Split('/'); + if (autoLOC_6001364.Length == 2) + { + autoLOC_Port = autoLOC_6001364[0]; + autoLOC_Stbd = autoLOC_6001364[1]; + } + + autoLOC_6001365_DorsalVentral = Localizer.Format("#autoLOC_6001365"); + string[] autoLOC_6001365 = autoLOC_6001365_DorsalVentral.Split('/'); + if (autoLOC_6001365.Length == 2) + { + autoLOC_Dorsal = autoLOC_6001365[0]; + autoLOC_Ventral = autoLOC_6001365[1]; + } + + autoLOC_6001366_ForeAft = Localizer.Format("#autoLOC_6001366"); + string[] autoLOC_6001366 = autoLOC_6001366_ForeAft.Split('/'); + if (autoLOC_6001366.Length == 2) + { + autoLOC_Fore = autoLOC_6001366[0]; + autoLOC_Aft = autoLOC_6001366[1]; + } + } + + private static string autoLOC_6001330_Pitch; + private static string autoLOC_6001331_Yaw; + private static string autoLOC_6001332_Roll; + private static string autoLOC_6001364_PortStbd; + private static string autoLOC_6001365_DorsalVentral; + private static string autoLOC_6001366_ForeAft; + + private static string autoLOC_Port; + private static string autoLOC_Stbd; + private static string autoLOC_Dorsal; + private static string autoLOC_Ventral; + private static string autoLOC_Fore; + private static string autoLOC_Aft; + + internal static Dictionary moduleRCSExtensions; + + static void ModuleRCS_OnAwake_Postfix(ModuleRCS __instance) + { + moduleRCSExtensions.Add(__instance, new ModuleRCSExtension(__instance)); + } + + static void ModuleRCS_OnDestroy_Postfix(ModuleRCS __instance) + { + moduleRCSExtensions.Remove(__instance); + } + + static void ModuleRCS_UpdateToggles_Postfix(ModuleRCS __instance) + { + if (moduleRCSExtensions.TryGetValue(__instance, out ModuleRCSExtension rcsExt)) + { + bool guiActive = __instance.showToggles && __instance.currentShowToggles && __instance.moduleIsEnabled; + rcsExt.minRotationAlignementField.guiActive = guiActive; + rcsExt.minRotationAlignementField.guiActiveEditor = guiActive; + rcsExt.minLinearAlignementField.guiActive = guiActive; + rcsExt.minLinearAlignementField.guiActiveEditor = guiActive; + } + } + + static bool ActuationToggleDisplayed(ModuleRCS mrcs) + { + if (!mrcs.showToggles || !mrcs.currentShowToggles) + return false; + + if (mrcs.part.PartActionWindow == null || !mrcs.part.PartActionWindow.isActiveAndEnabled) + return false; + + return true; + } + + internal class ModuleRCSExtension + { + public static FieldInfo minRotationAlignementFieldInfo = AccessTools.Field(typeof(ModuleRCSExtension), nameof(minRotationAlignement)); + public static KSPField minRotationAlignementKSPField = new KSPField(); + public static UI_FloatRange minRotationAlignementControl = new UI_FloatRange(); + + public static FieldInfo minLinearAlignementFieldInfo = AccessTools.Field(typeof(ModuleRCSExtension), nameof(minLinearAlignement)); + public static KSPField minLinearAlignementKSPField = new KSPField(); + public static UI_FloatRange minLinearAlignementControl = new UI_FloatRange(); + + static ModuleRCSExtension() + { + minRotationAlignementKSPField.guiActive = false; + minRotationAlignementKSPField.guiActiveEditor = false; + minRotationAlignementKSPField.guiName = "Min rotation alignement"; + minRotationAlignementKSPField.guiFormat = "0°"; + minRotationAlignementKSPField.isPersistant = true; + + minRotationAlignementControl.minValue = 5f; + minRotationAlignementControl.maxValue = 90f; + minRotationAlignementControl.stepIncrement = 1f; + minRotationAlignementControl.affectSymCounterparts = UI_Scene.All; + + minLinearAlignementKSPField.guiActive = false; + minLinearAlignementKSPField.guiActiveEditor = false; + minLinearAlignementKSPField.guiName = "Min translation alignement"; + minLinearAlignementKSPField.guiFormat = "0°"; + minLinearAlignementKSPField.isPersistant = true; + + minLinearAlignementControl.minValue = 5f; + minLinearAlignementControl.maxValue = 90f; + minLinearAlignementControl.stepIncrement = 1f; + minLinearAlignementControl.affectSymCounterparts = UI_Scene.All; + } + + public ModuleRCS module; + + public BaseField minRotationAlignementField; + public float minRotationAlignement; + + public BaseField minLinearAlignementField; + public float minLinearAlignement; + + public float lastTorqueUpdateFrame; + + public BaseField enablePitchField; + public BaseField enableRollField; + public BaseField enableYawField; + public BaseField enableXField; + public BaseField enableYField; + public BaseField enableZField; + + public ModuleRCSExtension(ModuleRCS module) + { + this.module = module; + + minRotationAlignementField = new BaseField(minRotationAlignementKSPField, minRotationAlignementFieldInfo, this); + minRotationAlignementField.uiControlEditor = minRotationAlignementControl; + minRotationAlignementField.uiControlFlight = minRotationAlignementControl; + module.Fields.Add(minRotationAlignementField); + + minLinearAlignementField = new BaseField(minLinearAlignementKSPField, minLinearAlignementFieldInfo, this); + minLinearAlignementField.uiControlEditor = minLinearAlignementControl; + minLinearAlignementField.uiControlFlight = minLinearAlignementControl; + module.Fields.Add(minLinearAlignementField); + + enablePitchField = module.Fields[nameof(ModuleRCS.enablePitch)]; + enableRollField = module.Fields[nameof(ModuleRCS.enableRoll)]; + enableYawField = module.Fields[nameof(ModuleRCS.enableYaw)]; + enableXField = module.Fields[nameof(ModuleRCS.enableX)]; + enableYField = module.Fields[nameof(ModuleRCS.enableY)]; + enableZField = module.Fields[nameof(ModuleRCS.enableZ)]; + + int moduleIndex = module.part.modules.IndexOf(module); + if (moduleIndex != -1 + && module.part.partInfo?.partPrefab != null + && moduleIndex < module.part.partInfo.partPrefab.modules.Count + && module.part.partInfo.partPrefab.modules[moduleIndex] is ModuleRCS prefabModule + && moduleRCSExtensions.TryGetValue(prefabModule, out ModuleRCSExtension prefabLimits)) + { + minRotationAlignement = prefabLimits.minRotationAlignement; + minLinearAlignement = prefabLimits.minLinearAlignement; + } + else + { + minRotationAlignement = 90f; + minLinearAlignement = 90f; + } + } + + public void UpdatePAWTorqueAndForces(Transform referenceTransform, float thrustForce, Vector3 currentCoM) + { + Vector3 posTorque = Vector3.zero; + Vector3 negTorque = Vector3.zero; + Vector3 posForce = Vector3.zero; + Vector3 negForce = Vector3.zero; + + Quaternion controlRotation = referenceTransform.rotation; + + Vector3 pitchCtrl = controlRotation * Vector3.right; + Vector3 rollCtrl = controlRotation * Vector3.up; + Vector3 yawCtrl = controlRotation * Vector3.forward; + + float minRotActuation = Mathf.Max(Mathf.Cos(minRotationAlignement * Mathf.Deg2Rad), 0f); + float minLinActuation = Mathf.Max(Mathf.Cos(minLinearAlignement * Mathf.Deg2Rad), 0f); + + for (int i = module.thrusterTransforms.Count - 1; i >= 0; i--) + { + Transform thruster = module.thrusterTransforms[i]; + + if (!thruster.gameObject.activeInHierarchy) + continue; + + Vector3 thrusterPosFromCoM = thruster.position - currentCoM; + Vector3 thrusterDirFromCoM = thrusterPosFromCoM.normalized; + Vector3 thrustDirection = module.useZaxis ? thruster.forward : thruster.up; + + Vector3 thrusterThrust = thrustDirection * thrustForce; + Vector3 thrusterTorque = Vector3.Cross(thrusterPosFromCoM, thrusterThrust); + // transform in vessel control space + thrusterTorque = referenceTransform.InverseTransformDirection(thrusterTorque); + + if (module.enablePitch && Math.Abs(thrusterTorque.x) > 0.0001f) + { + Vector3 pitchRot = Vector3.Cross(pitchCtrl, Vector3.ProjectOnPlane(thrusterDirFromCoM, pitchCtrl)); + float actuation = Vector3.Dot(thrustDirection, pitchRot.normalized); + float actuationMagnitude = Math.Abs(actuation); + + if (actuationMagnitude < minRotActuation) + actuation = 0f; + else if (module.fullThrust && actuationMagnitude > module.fullThrustMin) + actuation = Math.Sign(actuation); + + if (actuation != 0f) + { + if (actuation > 0f) + posTorque.x += thrusterTorque.x * actuation; + else + negTorque.x += thrusterTorque.x * actuation; + } + } + + if (module.enableRoll && Math.Abs(thrusterTorque.y) > 0.0001f) + { + Vector3 rollRot = Vector3.Cross(rollCtrl, Vector3.ProjectOnPlane(thrusterDirFromCoM, rollCtrl)); + float actuation = Vector3.Dot(thrustDirection, rollRot.normalized); + float actuationMagnitude = Math.Abs(actuation); + + if (actuationMagnitude < minRotActuation) + actuation = 0f; + else if (module.fullThrust && actuationMagnitude > module.fullThrustMin) + actuation = Math.Sign(actuation); + + if (actuation != 0f) + { + if (actuation > 0f) + posTorque.y += thrusterTorque.y * actuation; + else + negTorque.y += thrusterTorque.y * actuation; + } + } + + if (module.enableYaw && Math.Abs(thrusterTorque.z) > 0.0001f) + { + Vector3 yawRot = Vector3.Cross(yawCtrl, Vector3.ProjectOnPlane(thrusterDirFromCoM, yawCtrl)); + float actuation = Vector3.Dot(thrustDirection, yawRot.normalized); + float actuationMagnitude = Math.Abs(actuation); + + if (actuationMagnitude < minRotActuation) + actuation = 0f; + else if (module.fullThrust && actuationMagnitude > module.fullThrustMin) + actuation = Math.Sign(actuation); + + if (actuation != 0f) + { + if (actuation > 0f) + posTorque.z += thrusterTorque.z * actuation; + else + negTorque.z += thrusterTorque.z * actuation; + } + } + + if (module.enableX) + { + float actuation = Vector3.Dot(thrustDirection, pitchCtrl); + float actuationMagnitude = Math.Abs(actuation); + + if (actuationMagnitude < minLinActuation) + actuation = 0f; + else if (module.fullThrust && actuationMagnitude > module.fullThrustMin) + actuation = Math.Sign(actuation); + + if (actuation != 0f) + { + if (actuation > 0f) + posForce.x += thrustForce * actuation; + else + negForce.x -= thrustForce * actuation; + } + } + + if (module.enableY) + { + float actuation = Vector3.Dot(thrustDirection, yawCtrl); + float actuationMagnitude = Math.Abs(actuation); + + if (actuationMagnitude < minLinActuation) + actuation = 0f; + else if (module.fullThrust && actuationMagnitude > module.fullThrustMin) + actuation = Math.Sign(actuation); + + if (actuation != 0f) + { + if (actuation > 0f) + posForce.z += thrustForce * actuation; + else + negForce.z -= thrustForce * actuation; + } + } + + if (module.enableZ) + { + float actuation = Vector3.Dot(thrustDirection, rollCtrl); + float actuationMagnitude = Math.Abs(actuation); + + if (actuationMagnitude < minLinActuation) + actuation = 0f; + else if (module.fullThrust && actuationMagnitude > module.fullThrustMin) + actuation = Math.Sign(actuation); + + if (actuation != 0f) + { + if (actuation > 0f) + posForce.y += thrustForce * actuation; + else + negForce.y -= thrustForce * actuation; + } + } + } + + if (module.enablePitch) + SetToggleGuiName(enablePitchField, $"{autoLOC_6001330_Pitch}: {Math.Round(posTorque.x, 3):G3} / {-Math.Round(negTorque.x, 3):G3} kNm"); + else + SetToggleGuiName(enablePitchField, autoLOC_6001330_Pitch); + + if (module.enableRoll) + SetToggleGuiName(enableRollField, $"{autoLOC_6001332_Roll}: {Math.Round(posTorque.y, 3):G3} / {-Math.Round(negTorque.y, 3):G3} kNm"); + else + SetToggleGuiName(enableRollField, autoLOC_6001332_Roll); + + if (module.enableYaw) + SetToggleGuiName(enableYawField, $"{autoLOC_6001331_Yaw}: {Math.Round(posTorque.z, 3):G3} / {-Math.Round(negTorque.z, 3):G3} kNm"); + else + SetToggleGuiName(enableYawField, autoLOC_6001331_Yaw); + + if (module.enableX) + SetToggleGuiName(enableXField, autoLOC_Port == null + ? $"{autoLOC_6001364_PortStbd}: {Math.Round(posForce.x, 3):G1} / {Math.Round(negForce.x, 3):G1} kN" + : $"{autoLOC_Port}: {Math.Round(posForce.x, 3):G1}kN / {autoLOC_Stbd}: {Math.Round(negForce.x, 3):G1}kN"); + else + SetToggleGuiName(enableXField, autoLOC_6001364_PortStbd); + + if (module.enableY) + SetToggleGuiName(enableYField, autoLOC_Dorsal == null + ? $"{autoLOC_6001365_DorsalVentral}: {Math.Round(posForce.z, 3):G1} / {Math.Round(negForce.z, 3):G1} kN" + : $"{autoLOC_Dorsal}: {Math.Round(posForce.z, 3):G1}kN / {autoLOC_Ventral}: {Math.Round(negForce.z, 3):G1}kN"); + else + SetToggleGuiName(enableYField, autoLOC_6001365_DorsalVentral); + + if (module.enableZ) + SetToggleGuiName(enableZField, autoLOC_Fore == null + ? $"{autoLOC_6001366_ForeAft}: {Math.Round(posForce.y, 3):G1} / {Math.Round(negForce.y, 3):G1} kN" + : $"{autoLOC_Fore}: {Math.Round(posForce.y, 3):G1}kN / {autoLOC_Aft}: {Math.Round(negForce.y, 3):G1}kN"); + else + SetToggleGuiName(enableZField, autoLOC_6001366_ForeAft); + } + + public void DisableTorquePAWInfo() + { + SetToggleGuiName(enablePitchField, autoLOC_6001330_Pitch); + SetToggleGuiName(enableRollField, autoLOC_6001332_Roll); + SetToggleGuiName(enableYawField, autoLOC_6001331_Yaw); + SetToggleGuiName(enableXField, autoLOC_6001364_PortStbd); + SetToggleGuiName(enableYField, autoLOC_6001365_DorsalVentral); + SetToggleGuiName(enableZField, autoLOC_6001366_ForeAft); + } + + public static void SetToggleGuiName(BaseField baseField, string guiName) + { + baseField.guiName = guiName; + + UIPartActionToggle toggle; + + if (!ReferenceEquals(baseField.uiControlEditor.partActionItem, null)) + toggle = (UIPartActionToggle)baseField.uiControlEditor.partActionItem; + else if (!ReferenceEquals(baseField.uiControlFlight.partActionItem, null)) + toggle = (UIPartActionToggle)baseField.uiControlFlight.partActionItem; + else + return; + + toggle.fieldName.text = guiName; + toggle.fieldName.rectTransform.sizeDelta = new Vector2(150f, toggle.fieldName.rectTransform.sizeDelta.y); + + } + } + + #region Editor PAW update + + static void ModuleRCS_Update_Postfix(ModuleRCS __instance) + { + if (HighLogic.LoadedScene != GameScenes.EDITOR) + return; + + if (!ActuationToggleDisplayed(__instance)) + return; + + if (!moduleRCSExtensions.TryGetValue(__instance, out ModuleRCSExtension rcsExt)) + return; + + if (__instance.part.frozen + || !__instance.moduleIsEnabled + || !__instance.rcsEnabled + || __instance.isJustForShow + || (__instance.part.ShieldedFromAirstream && !__instance.shieldedCanThrust) + || !EditorSetupPropellant(__instance)) + { + rcsExt.DisableTorquePAWInfo(); + return; + } + + if (EditorPhysics.TryGetAndUpdate(out EditorPhysics editorPhysics)) + { + if (rcsExt.lastTorqueUpdateFrame < editorPhysics.lastShipModificationFrame) + { + rcsExt.lastTorqueUpdateFrame = editorPhysics.lastShipModificationFrame; + __instance.StartCoroutine(UpdatePAWDelayed(__instance, rcsExt)); + } + } + } + + private static IEnumerator UpdatePAWDelayed(ModuleRCS mrcs, ModuleRCSExtension rcsExt) + { + yield return null; + + if (EditorPhysics.TryGetAndUpdate(out EditorPhysics editorPhysics)) + { + mrcs.realISP = mrcs.atmosphereCurve.Evaluate((float)editorPhysics.atmStaticPressure); + double exhaustVel = mrcs.realISP * mrcs.G * mrcs.ispMult; + float thrustForce = (float)(exhaustVel * mrcs.maxFuelFlow * mrcs.flowMult * mrcs.thrustPercentage * 0.01); + + rcsExt.UpdatePAWTorqueAndForces(editorPhysics.referenceTransform, thrustForce, editorPhysics.CoM); + } + } + + + // ModuleRCS populate the propellant list in OnLoad(), and because the list isn't defined as serializable, it isn't populated for part instantiated in the editor. + // So check if the list is populated, and if not copy the prefab list. + private static bool EditorSetupPropellant(ModuleRCS mrcs) + { + if (mrcs.propellants.Count > 0) + return true; + + int moduleIdx = mrcs.part.modules.IndexOf(mrcs); + if (moduleIdx >= 0 && moduleIdx < mrcs.part.partInfo.partPrefab.modules.Count && mrcs.part.partInfo.partPrefab.modules[moduleIdx] is ModuleRCS prefabModule) + { + foreach (Propellant prefabPropellant in prefabModule.propellants) + mrcs.propellants.Add(JsonUtility.FromJson(JsonUtility.ToJson(prefabPropellant))); // Propellant is [Serializable], so lazy but effective + + mrcs.mixtureDensity = prefabModule.mixtureDensity; + mrcs.mixtureDensityRecip = prefabModule.mixtureDensityRecip; + mrcs.maxFuelFlow = prefabModule.maxFuelFlow; + } + + return mrcs.propellants.Count > 0; + } + + #endregion + + #region FixedUpdate + + static bool ModuleRCS_FixedUpdate_Prefix(ModuleRCS __instance) + { + __instance.isOperating = false; + if (!HighLogic.LoadedSceneIsFlight) + { + return false; + } + + if (TimeWarp.CurrentRate > 1f && TimeWarp.WarpMode == TimeWarp.Modes.HIGH) + { + if (ActuationToggleDisplayed(__instance) && moduleRCSExtensions.TryGetValue(__instance, out ModuleRCSExtension rcsExt)) + rcsExt.DisableTorquePAWInfo(); + + __instance.DeactivatePowerFX(); + return false; + } + + __instance.tC = __instance.thrusterTransforms.Count; + if (__instance.thrustForces.Length != __instance.tC) + { + __instance.thrustForces = new float[__instance.tC]; + } + + int thrusterIdx = __instance.tC; + while (thrusterIdx-- > 0) + { + __instance.thrustForces[thrusterIdx] = 0f; + } + + __instance.totalThrustForce = 0f; + __instance.realISP = __instance.atmosphereCurve.Evaluate((float)__instance.part.staticPressureAtm); + __instance.exhaustVel = __instance.realISP * __instance.G * __instance.ispMult; + __instance.thrustForceRecip = 1f / __instance.thrusterPower; + if (__instance.moduleIsEnabled && __instance.vessel != null && __instance.rcsEnabled && !__instance.IsAdjusterBreakingRCS() && (!__instance.part.ShieldedFromAirstream || __instance.shieldedCanThrust)) + { + ModuleRCSExtension rcsExt = null; + bool rcsActive; + if ((rcsActive = __instance.vessel.ActionGroups[KSPActionGroup.RCS]) != __instance.rcs_active) + { + __instance.rcs_active = rcsActive; + } + if (__instance.rcs_active && (__instance.inputRot != Vector3.zero || __instance.inputLin != Vector3.zero)) + { + __instance.predictedCOM = __instance.vessel.CurrentCoM; + + float rotLimit, linLimit; + if (moduleRCSExtensions.TryGetValue(__instance, out rcsExt)) + { + rotLimit = Mathf.Max(Mathf.Cos(rcsExt.minRotationAlignement * Mathf.Deg2Rad), 0f); + linLimit = Mathf.Max(Mathf.Cos(rcsExt.minLinearAlignement * Mathf.Deg2Rad), 0f); + } + else + { + rotLimit = 0f; + linLimit = 0f; + } + + bool success = false; + thrusterIdx = __instance.tC; + while (thrusterIdx-- > 0) + { + __instance.currentThruster = __instance.thrusterTransforms[thrusterIdx]; + if (__instance.currentThruster.position == Vector3.zero || !__instance.currentThruster.gameObject.activeInHierarchy) + continue; + + Vector3 thrustDir = ((!__instance.useZaxis) ? __instance.currentThruster.up : __instance.currentThruster.forward); + __instance.rot = Vector3.Cross(__instance.inputRot, Vector3.ProjectOnPlane(__instance.currentThruster.position - __instance.predictedCOM, __instance.inputRot).normalized); + __instance.currentThrustForce = Vector3.Dot(thrustDir, __instance.rot); + + if (__instance.currentThrustForce < rotLimit) + __instance.currentThrustForce = 0f; + + float linDot = Vector3.Dot(thrustDir, __instance.inputLin); + if (linDot >= linLimit) + __instance.currentThrustForce += linDot; + + if (__instance.currentThrustForce == 0f) + { + __instance.thrustForces[thrusterIdx] = 0f; + __instance.isOperating |= false; + __instance.UpdatePowerFX(false, thrusterIdx, 0f); + continue; + } + + if (__instance.currentThrustForce > 1f) + { + __instance.currentThrustForce = 1f; + } + if (__instance.fullThrust && __instance.currentThrustForce >= __instance.fullThrustMin) + { + __instance.currentThrustForce = 1f; + } + if (__instance.usePrecision) + { + if (__instance.useLever) + { + __instance.leverDistance = __instance.GetLeverDistance(__instance.currentThruster, -thrustDir, __instance.predictedCOM); + if (__instance.leverDistance > 1f) + { + __instance.currentThrustForce /= __instance.leverDistance; + } + } + else + { + __instance.currentThrustForce *= __instance.precisionFactor; + } + } + __instance.UpdatePropellantStatus(); + __instance.currentThrustForce = __instance.CalculateThrust(__instance.currentThrustForce, out success); + __instance.thrustForces[thrusterIdx] = __instance.currentThrustForce; + bool isThrusting = __instance.currentThrustForce > 0f && success; + __instance.isOperating |= isThrusting; + __instance.UpdatePowerFX(isThrusting, thrusterIdx, Mathf.Clamp(__instance.currentThrustForce * __instance.thrustForceRecip, 0.1f, 1f)); + if (isThrusting && !__instance.isJustForShow) + { + __instance.totalThrustForce += __instance.currentThrustForce; + __instance.part.AddForceAtPosition(-thrustDir * __instance.currentThrustForce, __instance.currentThruster.transform.position); + } + } + } + else + { + __instance.DeactivateFX(); + } + + if (ActuationToggleDisplayed(__instance) && (rcsExt != null || moduleRCSExtensions.TryGetValue(__instance, out rcsExt))) + { + float thrustForce = (float)(__instance.exhaustVel * __instance.maxFuelFlow * __instance.flowMult * __instance.thrustPercentage * 0.01); + rcsExt.UpdatePAWTorqueAndForces(__instance.vessel.ReferenceTransform, thrustForce, __instance.vessel.CurrentCoM); + } + } + else + { + __instance.DeactivateFX(); + + if (ActuationToggleDisplayed(__instance) && moduleRCSExtensions.TryGetValue(__instance, out ModuleRCSExtension rcsExt)) + rcsExt.DisableTorquePAWInfo(); + } + + return false; + } + + #endregion + + + } +} diff --git a/MultipleModuleInPartAPI/MultipleModuleInPartAPI.csproj b/MultipleModuleInPartAPI/MultipleModuleInPartAPI.csproj index 08970b5..3757c74 100644 --- a/MultipleModuleInPartAPI/MultipleModuleInPartAPI.csproj +++ b/MultipleModuleInPartAPI/MultipleModuleInPartAPI.csproj @@ -40,6 +40,14 @@ prompt 4 + + portable + true + bin\Profiling\ + TRACE + prompt + 4 + System (KSP/Mono) diff --git a/README.md b/README.md index a85c445..7920c74 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,6 @@ User options are available from the "ESC" in-game settings menu :
Fix ROCManager crashing during loading with Kopernicus modified systems. -- **ReactionWheelsPotentialTorque** [KSP 1.8.0 - 1.12.5]
Fix reaction wheels reporting incorrect available torque when "Wheel Authority" is set below 100%. Fix stock SAS (and possibly other attitude controllers) instability issues. - **StockAlarmCustomFormatterDate** [KSP 1.12.0 - 1.12.5]
Make the stock alarm respect the day/year length defined by mods like Kronometer. Fix the underlying AppUIMemberDateTime UI widget API to use the mod-provided IDateTimeFormatter if present. - **[StockAlarmDescPreserveLineBreak](https://github.com/KSPModdingLibs/KSPCommunityFixes/issues/19)** [KSP 1.12.0 - 1.12.5]
Stock alarm preserve line breaks (and tabs) in the description field. - **ExtendedDeployableParts** [KSP 1.12.0 - 1.12.5]
Fix deployable parts (antennas, solar panels, radiators...) always starting in the extended state when the model isn't exported in the retracted state. This bug affect parts from various mods (ex : Ven's stock revamp solar panels).