diff --git a/RealFuels/Localization/en-us.cfg b/RealFuels/Localization/en-us.cfg
index c2a88e07..081e8b0c 100644
--- a/RealFuels/Localization/en-us.cfg
+++ b/RealFuels/Localization/en-us.cfg
@@ -44,7 +44,7 @@ Localization
#RF_Engine_GUIHide = Hide GUI
#RF_Engine_GUIShow = Show GUI
#RF_Engine_WindowTitle = Configure <<1>>
- #RF_Engine_CurrentConfig = Current config
+ #RF_Engine_CurrentConfig = Current
#RF_Engine_ConfigSwitch = Switch to
#RF_Engine_LacksTech = Lacks tech for <<1>>
#RF_Engine_Purchase = Purchase
diff --git a/RealFuels/RF_TestFlight_UISupport.cfg b/RealFuels/RF_TestFlight_UISupport.cfg
new file mode 100644
index 00000000..4a563f2d
--- /dev/null
+++ b/RealFuels/RF_TestFlight_UISupport.cfg
@@ -0,0 +1,23 @@
+// RealFuels TestFlight UI Support
+// This patch copies TestFlight reliability values from TESTFLIGHT nodes into CONFIG nodes
+// so the RealFuels engine configuration UI can display them.
+// This runs after RealismOverhaul but before TESTFLIGHT nodes are deleted.
+
+@PART[*]:HAS[@MODULE[Module*EngineConfigs]]:BEFORE[zTestFlight]
+
+{
+ @MODULE[Module*EngineConfigs]
+ {
+ // Copy reliability values for RealFuels UI display
+ @CONFIG:HAS[@TESTFLIGHT:HAS[#ignitionReliabilityStart]] { &ignitionReliabilityStart = #$TESTFLIGHT/ignitionReliabilityStart$ }
+ @CONFIG:HAS[@TESTFLIGHT:HAS[#ignitionReliabilityEnd]] { &ignitionReliabilityEnd = #$TESTFLIGHT/ignitionReliabilityEnd$ }
+ @CONFIG:HAS[@TESTFLIGHT:HAS[#cycleReliabilityStart]] { &cycleReliabilityStart = #$TESTFLIGHT/cycleReliabilityStart$ }
+ @CONFIG:HAS[@TESTFLIGHT:HAS[#cycleReliabilityEnd]] { &cycleReliabilityEnd = #$TESTFLIGHT/cycleReliabilityEnd$ }
+
+ // Copy burn time values for failure probability chart
+ @CONFIG:HAS[@TESTFLIGHT:HAS[#ratedBurnTime]] { &ratedBurnTime = #$TESTFLIGHT/ratedBurnTime$ }
+ @CONFIG:HAS[@TESTFLIGHT:HAS[#ratedContinuousBurnTime]] { &ratedContinuousBurnTime = #$TESTFLIGHT/ratedContinuousBurnTime$ }
+ @CONFIG:HAS[@TESTFLIGHT:HAS[#testedBurnTime]] { &testedBurnTime = #$TESTFLIGHT/testedBurnTime$ }
+ @CONFIG:HAS[@TESTFLIGHT:HAS[#overburnPenalty]] { &overburnPenalty = #$TESTFLIGHT/overburnPenalty$ }
+ }
+}
diff --git a/Source/Engines/EngineConfigIntegrations.cs b/Source/Engines/EngineConfigIntegrations.cs
new file mode 100644
index 00000000..ee19ccb0
--- /dev/null
+++ b/Source/Engines/EngineConfigIntegrations.cs
@@ -0,0 +1,182 @@
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.Linq;
+using System.Reflection;
+using UnityEngine;
+
+namespace RealFuels
+{
+ ///
+ /// Handles integration with B9PartSwitch and TestFlight mods.
+ /// Manages B9PS module linking and TestFlight interop values.
+ ///
+ public class EngineConfigIntegrations
+ {
+ private readonly ModuleEngineConfigsBase _module;
+
+ // B9PartSwitch reflection fields
+ protected static bool _b9psReflectionInitialized = false;
+ protected static FieldInfo B9PS_moduleID;
+ protected static MethodInfo B9PS_SwitchSubtype;
+ protected static FieldInfo B9PS_switchInFlight;
+
+ public Dictionary B9PSModules;
+ protected Dictionary RequestedB9PSVariants = new Dictionary();
+
+ public EngineConfigIntegrations(ModuleEngineConfigsBase module)
+ {
+ _module = module;
+ InitializeB9PSReflection();
+ }
+
+ #region TestFlight Integration
+
+ ///
+ /// Updates TestFlight interop values with current configuration.
+ ///
+ public void UpdateTFInterops()
+ {
+ TestFlightWrapper.AddInteropValue(_module.part, _module.isMaster ? "engineConfig" : "vernierConfig", _module.configuration, "RealFuels");
+ }
+
+ #endregion
+
+ #region B9PartSwitch Integration
+
+ ///
+ /// Initializes reflection for B9PartSwitch if available.
+ ///
+ private void InitializeB9PSReflection()
+ {
+ if (_b9psReflectionInitialized || !Utilities.B9PSFound) return;
+ B9PS_moduleID = Type.GetType("B9PartSwitch.CustomPartModule, B9PartSwitch")?.GetField("moduleID");
+ B9PS_SwitchSubtype = Type.GetType("B9PartSwitch.ModuleB9PartSwitch, B9PartSwitch")?.GetMethod("SwitchSubtype");
+ B9PS_switchInFlight = Type.GetType("B9PartSwitch.ModuleB9PartSwitch, B9PartSwitch")?.GetField("switchInFlight");
+ _b9psReflectionInitialized = true;
+ }
+
+ ///
+ /// Loads all B9PartSwitch modules that are linked to configs.
+ ///
+ public void LoadB9PSModules()
+ {
+ IEnumerable b9psModuleIDs = _module.configs
+ .Where(cfg => cfg.HasNode("LinkB9PSModule"))
+ .SelectMany(cfg => cfg.GetNodes("LinkB9PSModule"))
+ .Select(link => link?.GetValue("name"))
+ .Where(moduleID => moduleID != null)
+ .Distinct();
+
+ B9PSModules = new Dictionary(b9psModuleIDs.Count());
+
+ foreach (string moduleID in b9psModuleIDs)
+ {
+ var module = ModuleEngineConfigsBase.GetSpecifiedModules(_module.part, string.Empty, -1, "ModuleB9PartSwitch", false)
+ .FirstOrDefault(m => (string)B9PS_moduleID?.GetValue(m) == moduleID);
+ if (module == null)
+ Debug.LogError($"*RFMEC* B9PartSwitch module with ID {moduleID} was not found for {_module.part}!");
+ else
+ B9PSModules[moduleID] = module;
+ }
+ }
+
+ ///
+ /// Hide the GUI for all `ModuleB9PartSwitch`s managed by RF.
+ /// This is somewhat of a hack-ish approach...
+ ///
+ public void HideB9PSVariantSelectors()
+ {
+ if (B9PSModules == null) return;
+ foreach (var module in B9PSModules.Values)
+ {
+ module.Fields["currentSubtypeTitle"].guiActive = false;
+ module.Fields["currentSubtypeTitle"].guiActiveEditor = false;
+ module.Fields["currentSubtypeIndex"].guiActive = false;
+ module.Fields["currentSubtypeIndex"].guiActiveEditor = false;
+ module.Events["ShowSubtypesWindow"].guiActive = false;
+ module.Events["ShowSubtypesWindow"].guiActiveEditor = false;
+ }
+ }
+
+ ///
+ /// Coroutine to hide B9PS in-flight selector after a frame delay.
+ ///
+ private IEnumerator HideB9PSInFlightSelector_Coroutine(PartModule module)
+ {
+ yield return null;
+ module.Events["ShowSubtypesWindow"].guiActive = false;
+ }
+
+ ///
+ /// Requests B9PS variant changes for the given config.
+ ///
+ public void RequestB9PSVariantsForConfig(ConfigNode node)
+ {
+ if (B9PSModules == null || B9PSModules.Count == 0) return;
+ RequestedB9PSVariants.Clear();
+ if (node.GetNodes("LinkB9PSModule") is ConfigNode[] links)
+ {
+ foreach (ConfigNode link in links)
+ {
+ string moduleID = null, subtype = null;
+ if (!link.TryGetValue("name", ref moduleID))
+ Debug.LogError($"*RFMEC* Config `{_module.configurationDisplay}` of {_module.part} has a LinkB9PSModule specification without a name key!");
+ if (!link.TryGetValue("subtype", ref subtype))
+ Debug.LogError($"*RFMEC* Config `{_module.configurationDisplay}` of {_module.part} has a LinkB9PSModule specification without a subtype key!");
+ if (moduleID != null && subtype != null)
+ RequestedB9PSVariants[moduleID] = subtype;
+ }
+ }
+ _module.StartCoroutine(ApplyRequestedB9PSVariants_Coroutine());
+ }
+
+ ///
+ /// Coroutine that applies requested B9PS variant changes after a frame delay.
+ ///
+ protected IEnumerator ApplyRequestedB9PSVariants_Coroutine()
+ {
+ yield return new WaitForEndOfFrame();
+
+ if (RequestedB9PSVariants.Count == 0) yield break;
+
+ foreach (var entry in B9PSModules)
+ {
+ string moduleID = entry.Key;
+ PartModule module = entry.Value;
+
+ if (HighLogic.LoadedSceneIsFlight
+ && B9PS_switchInFlight != null
+ && !(bool)B9PS_switchInFlight.GetValue(module)) continue;
+
+ if (!RequestedB9PSVariants.TryGetValue(moduleID, out string subtypeName))
+ {
+ Debug.LogError($"*RFMEC* Config {_module.configurationDisplay} of {_module.part} does not specify a subtype for linked B9PS module with ID {moduleID}; defaulting to `{_module.configuration}`.");
+ subtypeName = _module.configuration;
+ }
+
+ B9PS_SwitchSubtype?.Invoke(module, new object[] { subtypeName });
+ if (HighLogic.LoadedSceneIsFlight) _module.StartCoroutine(HideB9PSInFlightSelector_Coroutine(module));
+ }
+
+ RequestedB9PSVariants.Clear();
+ // Clear symmetry counterparts' queues since B9PS already handles symmetry.
+ _module.DoForEachSymmetryCounterpart(mec => mec.Integrations.ClearRequestedB9PSVariants());
+ }
+
+ ///
+ /// Clears the requested B9PS variants queue.
+ ///
+ public void ClearRequestedB9PSVariants()
+ {
+ RequestedB9PSVariants.Clear();
+ }
+
+ ///
+ /// Updates B9PS variants based on current config.
+ ///
+ public void UpdateB9PSVariants() => RequestB9PSVariantsForConfig(_module.config);
+
+ #endregion
+ }
+}
diff --git a/Source/Engines/EngineConfigPropellants.cs b/Source/Engines/EngineConfigPropellants.cs
new file mode 100644
index 00000000..f85681e6
--- /dev/null
+++ b/Source/Engines/EngineConfigPropellants.cs
@@ -0,0 +1,91 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Reflection;
+using UnityEngine;
+using KSP.UI.Screens;
+
+namespace RealFuels
+{
+ ///
+ /// Static utility class for propellant and resource operations.
+ /// Contains pure functions for clearing float curves, propellant gauges, and RCS propellants.
+ ///
+ public static class EngineConfigPropellants
+ {
+ private static readonly FieldInfo MRCSConsumedResources = typeof(ModuleRCS).GetField("consumedResources", BindingFlags.NonPublic | BindingFlags.Instance);
+
+ ///
+ /// Clears all FloatCurves that need to be cleared based on config node or tech level.
+ ///
+ public static void ClearFloatCurves(Type mType, PartModule pm, ConfigNode cfg, int techLevel)
+ {
+ // clear all FloatCurves we need to clear (i.e. if our config has one, or techlevels are enabled)
+ bool delAtmo = cfg.HasNode("atmosphereCurve") || techLevel >= 0;
+ bool delDens = cfg.HasNode("atmCurve") || techLevel >= 0;
+ bool delVel = cfg.HasNode("velCurve") || techLevel >= 0;
+ foreach (FieldInfo field in mType.GetFields())
+ {
+ if (field.FieldType == typeof(FloatCurve) &&
+ ((field.Name.Equals("atmosphereCurve") && delAtmo)
+ || (field.Name.Equals("atmCurve") && delDens)
+ || (field.Name.Equals("velCurve") && delVel)))
+ {
+ field.SetValue(pm, new FloatCurve());
+ }
+ }
+ }
+
+ ///
+ /// Clears propellant gauges from the staging icon.
+ ///
+ public static void ClearPropellantGauges(Type mType, PartModule pm)
+ {
+ foreach (FieldInfo field in mType.GetFields(BindingFlags.NonPublic | BindingFlags.Instance))
+ {
+ if (field.FieldType == typeof(Dictionary) &&
+ field.GetValue(pm) is Dictionary boxes)
+ {
+ foreach (ProtoStageIconInfo v in boxes.Values)
+ {
+ try
+ {
+ if (v is ProtoStageIconInfo)
+ pm.part.stackIcon.RemoveInfo(v);
+ }
+ catch (Exception e)
+ {
+ Debug.LogError("*RFMEC* Trying to remove info box: " + e.Message);
+ }
+ }
+ boxes.Clear();
+ }
+ }
+ }
+
+ ///
+ /// Clears RCS propellants and reloads them from config.
+ ///
+ /// The part containing RCS modules
+ /// The configuration node to load propellants from
+ /// Action to execute DoConfig on the configuration
+ public static void ClearRCSPropellants(Part part, ConfigNode cfg, Action doConfigAction)
+ {
+ List RCSModules = part.Modules.OfType().ToList();
+ if (RCSModules.Count > 0)
+ {
+ doConfigAction(cfg);
+ foreach (var rcsModule in RCSModules)
+ {
+ if (cfg.HasNode("PROPELLANT"))
+ rcsModule.propellants.Clear();
+ rcsModule.Load(cfg);
+ List res = MRCSConsumedResources.GetValue(rcsModule) as List;
+ res.Clear();
+ foreach (Propellant p in rcsModule.propellants)
+ res.Add(p.resourceDef);
+ }
+ }
+ }
+ }
+}
diff --git a/Source/Engines/EngineConfigTechLevels.cs b/Source/Engines/EngineConfigTechLevels.cs
new file mode 100644
index 00000000..63a28212
--- /dev/null
+++ b/Source/Engines/EngineConfigTechLevels.cs
@@ -0,0 +1,248 @@
+using System;
+using UnityEngine;
+using RealFuels.TechLevels;
+using KSP.Localization;
+
+namespace RealFuels
+{
+ ///
+ /// Handles tech level calculations, validation, and UI for ModuleEngineConfigs.
+ /// Extracted to separate concerns and improve maintainability.
+ ///
+ public class EngineConfigTechLevels
+ {
+ private readonly ModuleEngineConfigsBase _module;
+
+ public EngineConfigTechLevels(ModuleEngineConfigsBase module)
+ {
+ _module = module;
+ }
+
+ #region Tech Level Validation
+
+ ///
+ /// Checks if a configuration is unlocked (entry cost paid).
+ ///
+ public static bool UnlockedConfig(ConfigNode config, Part p)
+ {
+ if (config == null)
+ return false;
+ if (!config.HasValue("name"))
+ return false;
+ if (EntryCostManager.Instance != null && HighLogic.CurrentGame != null && HighLogic.CurrentGame.Mode != Game.Modes.SANDBOX)
+ return EntryCostManager.Instance.ConfigUnlocked((RFSettings.Instance.usePartNameInConfigUnlock ? Utilities.GetPartName(p) : string.Empty) + config.GetValue("name"));
+ return true;
+ }
+
+ ///
+ /// Checks if a configuration can be used (tech requirement met).
+ ///
+ public static bool CanConfig(ConfigNode config)
+ {
+ if (config == null)
+ return false;
+ if (!config.HasValue("techRequired") || HighLogic.CurrentGame == null)
+ return true;
+ if (HighLogic.CurrentGame.Mode == Game.Modes.SANDBOX || ResearchAndDevelopment.GetTechnologyState(config.GetValue("techRequired")) == RDTech.State.Available)
+ return true;
+ return false;
+ }
+
+ ///
+ /// Checks if a tech level is unlocked (entry cost paid).
+ ///
+ public static bool UnlockedTL(string tlName, int newTL)
+ {
+ if (EntryCostManager.Instance != null && HighLogic.CurrentGame != null && HighLogic.CurrentGame.Mode != Game.Modes.SANDBOX)
+ return EntryCostManager.Instance.TLUnlocked(tlName) >= newTL;
+ return true;
+ }
+
+ #endregion
+
+ #region Tech Level Calculations
+
+ ///
+ /// Calculates thrust multiplier based on tech level difference.
+ ///
+ public double ThrustTL(ConfigNode cfg = null)
+ {
+ if (_module.techLevel != -1 && !_module.engineType.Contains("S"))
+ {
+ TechLevel oldTL = new TechLevel(), newTL = new TechLevel();
+ if (oldTL.Load(cfg ?? _module.config, _module.techNodes, _module.engineType, _module.origTechLevel) &&
+ newTL.Load(cfg ?? _module.config, _module.techNodes, _module.engineType, _module.techLevel))
+ return newTL.Thrust(oldTL);
+ }
+ return 1;
+ }
+
+ ///
+ /// Applies tech level thrust multiplier to a float value.
+ ///
+ public float ThrustTL(float thrust, ConfigNode cfg = null)
+ {
+ return (float)Math.Round(thrust * ThrustTL(cfg), 6);
+ }
+
+ ///
+ /// Applies tech level thrust multiplier to a string value.
+ ///
+ public float ThrustTL(string thrust, ConfigNode cfg = null)
+ {
+ float.TryParse(thrust, out float tmp);
+ return ThrustTL(tmp, cfg);
+ }
+
+ ///
+ /// Calculates mass multiplier based on tech level difference.
+ ///
+ public double MassTL(ConfigNode cfg = null)
+ {
+ if (_module.techLevel != -1)
+ {
+ TechLevel oldTL = new TechLevel(), newTL = new TechLevel();
+ if (oldTL.Load(cfg ?? _module.config, _module.techNodes, _module.engineType, _module.origTechLevel) &&
+ newTL.Load(cfg ?? _module.config, _module.techNodes, _module.engineType, _module.techLevel))
+ return newTL.Mass(oldTL, _module.engineType.Contains("S"));
+ }
+ return 1;
+ }
+
+ ///
+ /// Applies tech level mass multiplier to a float value.
+ ///
+ public float MassTL(float mass)
+ {
+ return (float)Math.Round(mass * MassTL(), 6);
+ }
+
+ ///
+ /// Calculates cost adjusted for tech level.
+ ///
+ public float CostTL(float cost, ConfigNode cfg = null)
+ {
+ TechLevel cTL = new TechLevel();
+ TechLevel oTL = new TechLevel();
+ if (cTL.Load(cfg, _module.techNodes, _module.engineType, _module.techLevel) &&
+ oTL.Load(cfg, _module.techNodes, _module.engineType, _module.origTechLevel) &&
+ _module.part.partInfo != null)
+ {
+ // Bit of a dance: we have to figure out the total cost of the part, but doing so
+ // also depends on us. So we zero out our contribution first
+ // and then restore configCost.
+ float oldCC = _module.configCost;
+ _module.configCost = 0f;
+ float totalCost = _module.part.partInfo.cost + _module.part.GetModuleCosts(_module.part.partInfo.cost);
+ _module.configCost = oldCC;
+ cost = (totalCost + cost) * (cTL.CostMult / oTL.CostMult) - totalCost;
+ }
+
+ return cost;
+ }
+
+ ///
+ /// Resolves ignition count based on tech level (supports negative values like -1 for TL-based).
+ ///
+ public int ConfigIgnitions(int ignitions)
+ {
+ if (ignitions < 0)
+ {
+ ignitions = _module.techLevel + ignitions;
+ if (ignitions < 1)
+ ignitions = 1;
+ }
+ else if (ignitions == 0 && !_module.literalZeroIgnitions)
+ ignitions = -1;
+ return ignitions;
+ }
+
+ #endregion
+
+ #region Tech Level UI
+
+ ///
+ /// Draws the tech level selector UI with +/- buttons.
+ ///
+ public void DrawTechLevelSelector()
+ {
+ // NK Tech Level
+ if (_module.techLevel != -1)
+ {
+ GUILayout.BeginHorizontal();
+
+ GUILayout.Label($"{Localizer.GetStringByTag("#RF_Engine_TechLevel")}: "); // Tech Level
+ string minusStr = "X";
+ bool canMinus = false;
+ if (TechLevel.CanTL(_module.config, _module.techNodes, _module.engineType, _module.techLevel - 1) && _module.techLevel > _module.minTechLevel)
+ {
+ minusStr = "-";
+ canMinus = true;
+ }
+ if (GUILayout.Button(minusStr) && canMinus)
+ {
+ _module.techLevel--;
+ _module.SetConfiguration();
+ _module.UpdateSymmetryCounterparts();
+ _module.MarkWindowDirty();
+ }
+ GUILayout.Label(_module.techLevel.ToString());
+ string plusStr = "X";
+ bool canPlus = false;
+ bool canBuy = false;
+ string tlName = Utilities.GetPartName(_module.part) + _module.configuration;
+ double tlIncrMult = (double)(_module.techLevel + 1 - _module.origTechLevel);
+ if (TechLevel.CanTL(_module.config, _module.techNodes, _module.engineType, _module.techLevel + 1) && _module.techLevel < _module.maxTechLevel)
+ {
+ if (UnlockedTL(tlName, _module.techLevel + 1))
+ {
+ plusStr = "+";
+ canPlus = true;
+ }
+ else
+ {
+ double cost = EntryCostManager.Instance.TLEntryCost(tlName) * tlIncrMult;
+ double sciCost = EntryCostManager.Instance.TLSciEntryCost(tlName) * tlIncrMult;
+ bool autobuy = true;
+ plusStr = string.Empty;
+ if (cost > 0d)
+ {
+ plusStr += cost.ToString("N0") + "√";
+ autobuy = false;
+ canBuy = true;
+ }
+ if (sciCost > 0d)
+ {
+ if (cost > 0d)
+ plusStr += "/";
+ autobuy = false;
+ canBuy = true;
+ plusStr += sciCost.ToString("N1") + "s";
+ }
+ if (autobuy)
+ {
+ // auto-upgrade
+ EntryCostManager.Instance.SetTLUnlocked(tlName, _module.techLevel + 1);
+ plusStr = "+";
+ canPlus = true;
+ canBuy = false;
+ }
+ }
+ }
+ if (GUILayout.Button(plusStr) && (canPlus || canBuy))
+ {
+ if (!canBuy || EntryCostManager.Instance.PurchaseTL(tlName, _module.techLevel + 1, tlIncrMult))
+ {
+ _module.techLevel++;
+ _module.SetConfiguration();
+ _module.UpdateSymmetryCounterparts();
+ _module.MarkWindowDirty();
+ }
+ }
+ GUILayout.EndHorizontal();
+ }
+ }
+
+ #endregion
+ }
+}
diff --git a/Source/Engines/ModuleBimodalEngineConfigs.cs b/Source/Engines/ModuleBimodalEngineConfigs.cs
index 5ce2d2f8..d2a93f81 100644
--- a/Source/Engines/ModuleBimodalEngineConfigs.cs
+++ b/Source/Engines/ModuleBimodalEngineConfigs.cs
@@ -121,35 +121,35 @@ public override string GetConfigInfo(ConfigNode config, bool addDescription = tr
return info;
}
- protected override void DrawConfigSelectors(IEnumerable availableConfigNodes)
+ public override IEnumerable BuildConfigRows()
{
- if (GUILayout.Button(new GUIContent(ToggleText, toggleButtonHoverInfo)))
- ToggleMode();
- foreach (var node in availableConfigNodes)
+ foreach (var node in FilteredDisplayConfigs(false))
{
bool hasSecondary = ConfigHasSecondary(node);
var nodeApplied = IsSecondaryMode && hasSecondary ? SecondaryConfig(node) : node;
- DrawSelectButton(
- nodeApplied,
- node.GetValue("name") == configuration,
- (configName) =>
+ string configName = node.GetValue("name");
+ yield return new ConfigRowDefinition
+ {
+ Node = nodeApplied,
+ DisplayName = GetConfigDisplayName(nodeApplied),
+ IsSelected = configName == configuration,
+ Indent = false,
+ Apply = () =>
{
activePatchName = IsSecondaryMode && hasSecondary ? SecondaryPatchName(node) : "";
GUIApplyConfig(configName);
- });
+ }
+ };
}
}
- protected override void DrawPartInfo()
+ protected internal override void DrawConfigSelectors(IEnumerable availableConfigNodes)
{
- using (new GUILayout.HorizontalScope())
- {
- GUILayout.Label($"{Localizer.GetStringByTag("#RF_BimodalEngine_Currentmode")}: {ActiveModeDescription}"); // Current mode
- }
- base.DrawPartInfo();
+ // Add custom toggle button UI before the config table
+ if (GUILayout.Button(new GUIContent(ToggleText, toggleButtonHoverInfo)))
+ ToggleMode();
}
-
[KSPAction("#RF_BimodalEngine_ToggleEngineMode")] // Toggle Engine Mode
public void ToggleAction(KSPActionParam _) => ToggleMode();
@@ -300,9 +300,9 @@ private IEnumerator DriveAnimation(bool forward)
if (animationStates == null) yield break;
bool b9psNeedsReset = false;
- if (B9PSModules != null && B9PSModules.Count != 0 && switchB9PSAtAnimationTime >= 0f)
+ if (Integrations.B9PSModules != null && Integrations.B9PSModules.Count != 0 && switchB9PSAtAnimationTime >= 0f)
{
- RequestB9PSVariantsForConfig(IsPrimaryMode ? SecondaryConfig(config) : GetConfigByName(configuration));
+ Integrations.RequestB9PSVariantsForConfig(IsPrimaryMode ? SecondaryConfig(config) : GetConfigByName(configuration));
b9psNeedsReset = true;
}
@@ -320,7 +320,7 @@ private IEnumerator DriveAnimation(bool forward)
if (forward && animState.normalizedTime >= switchB9PSAtAnimationTime
|| !forward && animState.normalizedTime <= switchB9PSAtAnimationTime)
{
- UpdateB9PSVariants();
+ Integrations.UpdateB9PSVariants();
b9psNeedsReset = false;
}
}
@@ -328,7 +328,7 @@ private IEnumerator DriveAnimation(bool forward)
}
SetAnimationSpeed(0f);
- if (b9psNeedsReset) UpdateB9PSVariants();
+ if (b9psNeedsReset) Integrations.UpdateB9PSVariants();
}
}
}
diff --git a/Source/Engines/ModuleEngineConfigs.cs b/Source/Engines/ModuleEngineConfigs.cs
index d5b06956..ddd9caf8 100644
--- a/Source/Engines/ModuleEngineConfigs.cs
+++ b/Source/Engines/ModuleEngineConfigs.cs
@@ -1,6 +1,7 @@
using System;
using System.Collections;
using System.Collections.Generic;
+using System.Globalization;
using System.Reflection;
using System.Linq;
using UnityEngine;
@@ -32,18 +33,18 @@ public Gimbal(float gimbalRange, float gimbalRangeXP, float gimbalRangeXN, float
public string Info()
{
if (new[] { gimbalRange, gimbalRangeXP, gimbalRangeXN, gimbalRangeYP, gimbalRangeYN }.Distinct().Count() == 1)
- return $"{gimbalRange:N1}d"; //
+ return $"{gimbalRange:0.#}°";
if (new[] { gimbalRangeXP, gimbalRangeXN, gimbalRangeYP, gimbalRangeYN }.Distinct().Count() == 1)
- return $"{gimbalRangeXP:N1}d"; //
+ return $"{gimbalRangeXP:0.#}°";
var ret = string.Empty;
if (gimbalRangeXP == gimbalRangeXN)
- ret += $"{gimbalRangeXP:N1}d pitch, "; //
+ ret += $"{gimbalRangeXP:0.#}° pitch, ";
else
- ret += $"+{gimbalRangeXP:N1}d/-{gimbalRangeXN:N1}d pitch, "; //
+ ret += $"+{gimbalRangeXP:0.#}°/-{gimbalRangeXN:0.#}° pitch, ";
if (gimbalRangeYP == gimbalRangeYN)
- ret += $"{gimbalRangeYP:N1}d yaw"; //
+ ret += $"{gimbalRangeYP:0.#}° yaw";
else
- ret += $"+{gimbalRangeYP:N1}d/-{gimbalRangeYN:N1}d yaw"; //
+ ret += $"+{gimbalRangeYP:0.#}°/-{gimbalRangeYN:0.#}° yaw";
return ret;
}
}
@@ -155,37 +156,44 @@ public override string GetConfigDisplayName(ConfigNode node)
var name = node.GetValue("name");
if (!node.HasValue(PatchNameKey))
return name;
- return $"{name} [Subconfig {node.GetValue(PatchNameKey)}]";
+ return node.GetValue(PatchNameKey); // Just show subconfig name without parent prefix
}
- protected override void DrawConfigSelectors(IEnumerable availableConfigNodes)
+ public override IEnumerable BuildConfigRows()
{
- foreach (var node in availableConfigNodes)
+ foreach (var node in FilteredDisplayConfigs(false))
{
- DrawSelectButton(
- node,
- node.GetValue("name") == configuration && activePatchName == "",
- (configName) =>
+ string configName = node.GetValue("name");
+ yield return new ConfigRowDefinition
+ {
+ Node = node,
+ DisplayName = GetConfigDisplayName(node),
+ IsSelected = configName == configuration && activePatchName == "",
+ Indent = false,
+ Apply = () =>
{
activePatchName = "";
GUIApplyConfig(configName);
- });
+ }
+ };
+
foreach (var patch in GetPatchesOfConfig(node))
{
var patchedNode = PatchConfig(node, patch, false);
string patchName = patch.GetValue("name");
- using (new GUILayout.HorizontalScope())
+ string patchedConfigName = configName;
+ yield return new ConfigRowDefinition
{
- GUILayout.Space(30);
- DrawSelectButton(
- patchedNode,
- node.GetValue("name") == configuration && patchName == activePatchName,
- (configName) =>
- {
- activePatchName = patchName;
- GUIApplyConfig(configName);
- });
- }
+ Node = patchedNode,
+ DisplayName = GetConfigDisplayName(patchedNode),
+ IsSelected = patchedConfigName == configuration && patchName == activePatchName,
+ Indent = true,
+ Apply = () =>
+ {
+ activePatchName = patchName;
+ GUIApplyConfig(patchedConfigName);
+ }
+ };
}
}
}
@@ -193,13 +201,11 @@ protected override void DrawConfigSelectors(IEnumerable availableCon
public class ModuleEngineConfigsBase : PartModule, IPartCostModifier, IPartMassModifier
{
- private static FieldInfo MRCSConsumedResources = typeof(ModuleRCS).GetField("consumedResources", BindingFlags.NonPublic | BindingFlags.Instance);
-
//protected const string groupName = "ModuleEngineConfigs";
public const string groupName = ModuleEnginesRF.groupName;
public const string groupDisplayName = "#RF_Engine_EngineConfigs"; // "Engine Configs"
#region Fields
- protected bool compatible = true;
+ internal bool compatible = true;
[KSPField(isPersistant = true)]
public string configuration = string.Empty;
@@ -298,130 +304,6 @@ public class ModuleEngineConfigsBase : PartModule, IPartCostModifier, IPartMassM
public float scale = 1f;
#endregion
- #region TestFlight
-
- public void UpdateTFInterops()
- {
- TestFlightWrapper.AddInteropValue(part, isMaster ? "engineConfig" : "vernierConfig", configuration, "RealFuels");
- }
- #endregion
-
- #region B9PartSwitch
- protected static bool _b9psReflectionInitialized = false;
- protected static FieldInfo B9PS_moduleID;
- protected static MethodInfo B9PS_SwitchSubtype;
- protected static FieldInfo B9PS_switchInFlight;
- public Dictionary B9PSModules;
- protected Dictionary RequestedB9PSVariants = new Dictionary();
-
- private void InitializeB9PSReflection()
- {
- if (_b9psReflectionInitialized || !Utilities.B9PSFound) return;
- B9PS_moduleID = Type.GetType("B9PartSwitch.CustomPartModule, B9PartSwitch")?.GetField("moduleID");
- B9PS_SwitchSubtype = Type.GetType("B9PartSwitch.ModuleB9PartSwitch, B9PartSwitch")?.GetMethod("SwitchSubtype");
- B9PS_switchInFlight = Type.GetType("B9PartSwitch.ModuleB9PartSwitch, B9PartSwitch")?.GetField("switchInFlight");
- _b9psReflectionInitialized = true;
- }
-
- private void LoadB9PSModules()
- {
- IEnumerable b9psModuleIDs = configs
- .Where(cfg => cfg.HasNode("LinkB9PSModule"))
- .SelectMany(cfg => cfg.GetNodes("LinkB9PSModule"))
- .Select(link => link?.GetValue("name"))
- .Where(moduleID => moduleID != null)
- .Distinct();
-
- B9PSModules = new Dictionary(b9psModuleIDs.Count());
-
- foreach (string moduleID in b9psModuleIDs)
- {
- var module = GetSpecifiedModules(part, string.Empty, -1, "ModuleB9PartSwitch", false)
- .FirstOrDefault(m => (string)B9PS_moduleID?.GetValue(m) == moduleID);
- if (module == null)
- Debug.LogError($"*RFMEC* B9PartSwitch module with ID {moduleID} was not found for {part}!");
- else
- B9PSModules[moduleID] = module;
- }
- }
-
- ///
- /// Hide the GUI for all `ModuleB9PartSwitch`s managed by RF.
- /// This is somewhat of a hack-ish approach...
- ///
- private void HideB9PSVariantSelectors()
- {
- if (B9PSModules == null) return;
- foreach (var module in B9PSModules.Values)
- {
- module.Fields["currentSubtypeTitle"].guiActive = false;
- module.Fields["currentSubtypeTitle"].guiActiveEditor = false;
- module.Fields["currentSubtypeIndex"].guiActive = false;
- module.Fields["currentSubtypeIndex"].guiActiveEditor = false;
- module.Events["ShowSubtypesWindow"].guiActive = false;
- module.Events["ShowSubtypesWindow"].guiActiveEditor = false;
- }
- }
-
- private IEnumerator HideB9PSInFlightSelector_Coroutine(PartModule module)
- {
- yield return null;
- module.Events["ShowSubtypesWindow"].guiActive = false;
- }
-
- protected void RequestB9PSVariantsForConfig(ConfigNode node)
- {
- if (B9PSModules == null || B9PSModules.Count == 0) return;
- RequestedB9PSVariants.Clear();
- if (node.GetNodes("LinkB9PSModule") is ConfigNode[] links)
- {
- foreach (ConfigNode link in links)
- {
- string moduleID = null, subtype = null;
- if (!link.TryGetValue("name", ref moduleID))
- Debug.LogError($"*RFMEC* Config `{configurationDisplay}` of {part} has a LinkB9PSModule specification without a name key!");
- if (!link.TryGetValue("subtype", ref subtype))
- Debug.LogError($"*RFMEC* Config `{configurationDisplay}` of {part} has a LinkB9PSModule specification without a subtype key!");
- if (moduleID != null && subtype != null)
- RequestedB9PSVariants[moduleID] = subtype;
- }
- }
- StartCoroutine(ApplyRequestedB9PSVariants_Coroutine());
- }
-
- protected IEnumerator ApplyRequestedB9PSVariants_Coroutine()
- {
- yield return new WaitForEndOfFrame();
-
- if (RequestedB9PSVariants.Count == 0) yield break;
-
- foreach (var entry in B9PSModules)
- {
- string moduleID = entry.Key;
- PartModule module = entry.Value;
-
- if (HighLogic.LoadedSceneIsFlight
- && B9PS_switchInFlight != null
- && !(bool)B9PS_switchInFlight.GetValue(module)) continue;
-
- if (!RequestedB9PSVariants.TryGetValue(moduleID, out string subtypeName))
- {
- Debug.LogError($"*RFMEC* Config {configurationDisplay} of {part} does not specify a subtype for linked B9PS module with ID {moduleID}; defaulting to `{configuration}`.");
- subtypeName = configuration;
- }
-
- B9PS_SwitchSubtype?.Invoke(module, new object[] { subtypeName });
- if (HighLogic.LoadedSceneIsFlight) StartCoroutine(HideB9PSInFlightSelector_Coroutine(module));
- }
-
- RequestedB9PSVariants.Clear();
- // Clear symmetry counterparts' queues since B9PS already handles symmetry.
- DoForEachSymmetryCounterpart(mec => mec.RequestedB9PSVariants.Clear());
- }
-
- public void UpdateB9PSVariants() => RequestB9PSVariantsForConfig(config);
- #endregion
-
#region Callbacks
public float GetModuleCost(float stdCost, ModifierStagingSituation sit) => configCost;
public ModifierChangeWhen GetModuleCostChangeWhen() => ModifierChangeWhen.FIXED;
@@ -474,7 +356,7 @@ public static void RelocateRCSPawItems(ModuleRCS module)
field.group = new BasePAWGroup(groupName, groupDisplayName, false);
}
- private List FilteredDisplayConfigs(bool update)
+ internal List FilteredDisplayConfigs(bool update)
{
if (update || filteredDisplayConfigs == null)
{
@@ -488,7 +370,6 @@ public override void OnAwake()
{
techNodes = new ConfigNode();
configs = new List();
- InitializeB9PSReflection();
}
public override void OnLoad(ConfigNode node)
@@ -540,11 +421,14 @@ public override void OnStart(StartState state)
Fields[nameof(showRFGUI)].guiActiveEditor = isMaster;
if (HighLogic.LoadedSceneIsEditor)
+ {
GameEvents.onPartActionUIDismiss.Add(OnPartActionGuiDismiss);
+ GameEvents.onPartActionUIShown.Add(OnPartActionUIShown);
+ }
ConfigSaveLoad();
- LoadB9PSModules();
+ Integrations.LoadB9PSModules();
LoadDefaultGimbals();
@@ -558,7 +442,7 @@ public override void OnStart(StartState state)
public override void OnStartFinished(StartState state)
{
- HideB9PSVariantSelectors();
+ Integrations.HideB9PSVariantSelectors();
if (pModule is ModuleRCS mrcs) RelocateRCSPawItems(mrcs);
}
#endregion
@@ -588,7 +472,7 @@ private string TLTInfo()
float gimbalR = -1f;
if (config.HasValue("gimbalRange"))
- gimbalR = float.Parse(config.GetValue("gimbalRange"));
+ gimbalR = float.Parse(config.GetValue("gimbalRange"), CultureInfo.InvariantCulture);
else if (!gimbalTransform.Equals(string.Empty) || useGimbalAnyway)
{
if (cTL != null)
@@ -634,12 +518,12 @@ protected string ConfigInfoString(ConfigNode config, bool addDescription, bool c
if (config.HasValue(thrustRating))
{
- info.Append($" {Utilities.FormatThrust(scale * ThrustTL(config.GetValue(thrustRating), config))}");
+ info.Append($" {Utilities.FormatThrust(scale * TechLevels.ThrustTL(config.GetValue(thrustRating), config))}");
// add throttling info if present
if (config.HasValue("minThrust"))
- info.Append($", {Localizer.GetStringByTag("#RF_Engine_minThrustInfo")} {float.Parse(config.GetValue("minThrust")) / float.Parse(config.GetValue(thrustRating)):P0}"); //min
+ info.Append($", {Localizer.GetStringByTag("#RF_Engine_minThrustInfo")} {float.Parse(config.GetValue("minThrust"), CultureInfo.InvariantCulture) / float.Parse(config.GetValue(thrustRating), CultureInfo.InvariantCulture):P0}"); //min
else if (config.HasValue("throttle"))
- info.Append($", {Localizer.GetStringByTag("#RF_Engine_minThrustInfo")} {float.Parse(config.GetValue("throttle")):P0}"); // min
+ info.Append($", {Localizer.GetStringByTag("#RF_Engine_minThrustInfo")} {float.Parse(config.GetValue("throttle"), CultureInfo.InvariantCulture):P0}"); // min
}
else
info.Append($" {Localizer.GetStringByTag("#RF_Engine_UnknownThrust")}"); // Unknown Thrust
@@ -669,6 +553,24 @@ protected string ConfigInfoString(ConfigNode config, bool addDescription, bool c
info.Append($" {Localizer.GetStringByTag("#RF_Engine_Isp")}: {ispSL:N0} - {ispV:N0}s\n"); // Isp
}
+ if (config.HasNode("PROPELLANT"))
+ {
+ var propellants = config.GetNodes("PROPELLANT")
+ .Select(node =>
+ {
+ string name = node.GetValue("name");
+ string ratioStr = null;
+ if (node.TryGetValue("ratio", ref ratioStr) && float.TryParse(ratioStr, out float ratio))
+ return $"{name} ({ratio:N3})";
+ return name;
+ })
+ .Where(name => !string.IsNullOrWhiteSpace(name));
+
+ string propellantList = string.Join(", ", propellants);
+ if (!string.IsNullOrWhiteSpace(propellantList))
+ info.Append($" {Localizer.GetStringByTag("#RF_EngineRF_Propellant")}: {propellantList}\n");
+ }
+
if (config.HasValue("ratedBurnTime"))
{
if (config.HasValue("ratedContinuousBurnTime"))
@@ -730,7 +632,7 @@ protected string ConfigInfoString(ConfigNode config, bool addDescription, bool c
info.Append("\n");
}
if (config.HasValue("cost") && float.TryParse(config.GetValue("cost"), out float cst))
- info.Append($" ({scale * cst:N0} {Localizer.GetStringByTag("#RF_Engine_extraCost")} )\n"); // extra cost// FIXME should get cost from TL, but this should be safe
+ info.Append($" ({scale * cst:N0}√ {Localizer.GetStringByTag("#RF_Engine_extraCost")} )\n"); // extra cost// FIXME should get cost from TL, but this should be safe
if (addDescription && config.HasValue("description"))
info.Append($"\n {config.GetValue("description")}\n");
@@ -814,11 +716,11 @@ protected void SetConfiguration(ConfigNode newConfig, bool resetTechLevels)
Type mType = pModule.GetType();
config.SetValue("name", mType.Name);
- ClearFloatCurves(mType, pModule, config, techLevel);
- ClearPropellantGauges(mType, pModule);
+ EngineConfigPropellants.ClearFloatCurves(mType, pModule, config, techLevel);
+ EngineConfigPropellants.ClearPropellantGauges(mType, pModule);
if (type.Equals("ModuleRCS") || type.Equals("ModuleRCSFX"))
- ClearRCSPropellants(config);
+ EngineConfigPropellants.ClearRCSPropellants(part, config, DoConfig);
else
{ // is an ENGINE
if (pModule is ModuleEngines mE && config.HasNode("PROPELLANT"))
@@ -833,7 +735,7 @@ protected void SetConfiguration(ConfigNode newConfig, bool resetTechLevels)
{
if (int.TryParse(config.GetValue("ignitions"), out int tmpIgnitions))
{
- Ignitions = ConfigIgnitions(tmpIgnitions);
+ Ignitions = TechLevels.ConfigIgnitions(tmpIgnitions);
config.SetValue("ignitions", Ignitions.Value);
}
@@ -876,9 +778,9 @@ protected void SetConfiguration(ConfigNode newConfig, bool resetTechLevels)
SetupFX();
- UpdateB9PSVariants();
+ Integrations.UpdateB9PSVariants();
- UpdateTFInterops(); // update TestFlight if it's installed
+ Integrations.UpdateTFInterops(); // update TestFlight if it's installed
StopFX();
}
@@ -913,82 +815,8 @@ virtual public void SetConfiguration(string newConfiguration = null, bool resetT
SetConfiguration(newConfig, resetTechLevels);
}
- virtual protected int ConfigIgnitions(int ignitions)
- {
- if (ignitions < 0)
- {
- ignitions = techLevel + ignitions;
- if (ignitions < 1)
- ignitions = 1;
- }
- else if (ignitions == 0 && !literalZeroIgnitions)
- ignitions = -1;
- return ignitions;
- }
-
#region SetConfiguration Tools
- private void ClearFloatCurves(Type mType, PartModule pm, ConfigNode cfg, int techLevel)
- {
- // clear all FloatCurves we need to clear (i.e. if our config has one, or techlevels are enabled)
- bool delAtmo = cfg.HasNode("atmosphereCurve") || techLevel >= 0;
- bool delDens = cfg.HasNode("atmCurve") || techLevel >= 0;
- bool delVel = cfg.HasNode("velCurve") || techLevel >= 0;
- foreach (FieldInfo field in mType.GetFields())
- {
- if (field.FieldType == typeof(FloatCurve) &&
- ((field.Name.Equals("atmosphereCurve") && delAtmo)
- || (field.Name.Equals("atmCurve") && delDens)
- || (field.Name.Equals("velCurve") && delVel)))
- {
- field.SetValue(pm, new FloatCurve());
- }
- }
- }
-
- private void ClearPropellantGauges(Type mType, PartModule pm)
- {
- foreach (FieldInfo field in mType.GetFields(BindingFlags.NonPublic | BindingFlags.Instance))
- {
- if (field.FieldType == typeof(Dictionary) &&
- field.GetValue(pm) is Dictionary boxes)
- {
- foreach (ProtoStageIconInfo v in boxes.Values)
- {
- try
- {
- if (v is ProtoStageIconInfo)
- pm.part.stackIcon.RemoveInfo(v);
- }
- catch (Exception e)
- {
- Debug.LogError("*RFMEC* Trying to remove info box: " + e.Message);
- }
- }
- boxes.Clear();
- }
- }
- }
-
- private void ClearRCSPropellants(ConfigNode cfg)
- {
- List RCSModules = part.Modules.OfType().ToList();
- if (RCSModules.Count > 0)
- {
- DoConfig(cfg);
- foreach (var rcsModule in RCSModules)
- {
- if (cfg.HasNode("PROPELLANT"))
- rcsModule.propellants.Clear();
- rcsModule.Load(cfg);
- List res = MRCSConsumedResources.GetValue(rcsModule) as List;
- res.Clear();
- foreach (Propellant p in rcsModule.propellants)
- res.Add(p.resourceDef);
- }
- }
- }
-
- private Dictionary ExtractGimbals(ConfigNode cfg)
+ internal Dictionary ExtractGimbals(ConfigNode cfg)
{
Gimbal ExtractGimbalKeys(ConfigNode c)
{
@@ -1070,7 +898,7 @@ private void HandleEngineIgnitor(ConfigNode cfg)
if (eiNode.HasValue("ignitionsAvailable") &&
int.TryParse(eiNode.GetValue("ignitionsAvailable"), out int ignitions))
{
- ignitions = ConfigIgnitions(ignitions);
+ ignitions = TechLevels.ConfigIgnitions(ignitions);
eiNode.SetValue("ignitionsAvailable", ignitions);
eiNode.SetValue("ignitionsRemained", ignitions, true);
}
@@ -1121,11 +949,11 @@ virtual public void DoConfig(ConfigNode cfg)
float gimbal = -1f;
if (cfg.HasValue("gimbalRange"))
- gimbal = float.Parse(cfg.GetValue("gimbalRange"));
+ gimbal = float.Parse(cfg.GetValue("gimbalRange"), CultureInfo.InvariantCulture);
float cost = 0f;
if (cfg.HasValue("cost"))
- cost = scale * float.Parse(cfg.GetValue("cost"));
+ cost = scale * float.Parse(cfg.GetValue("cost"), CultureInfo.InvariantCulture);
if (techLevel != -1)
{
@@ -1155,14 +983,14 @@ virtual public void DoConfig(ConfigNode cfg)
// set heatProduction
if (configHeat > 0)
- configHeat = MassTL(configHeat);
+ configHeat = TechLevels.MassTL(configHeat);
// set thrust and throttle
if (configMaxThrust >= 0)
{
- configMaxThrust = ThrustTL(configMaxThrust);
+ configMaxThrust = TechLevels.ThrustTL(configMaxThrust);
if (configMinThrust >= 0)
- configMinThrust = ThrustTL(configMinThrust);
+ configMinThrust = TechLevels.ThrustTL(configMinThrust);
else if (thrustRating.Equals("thrusterPower"))
configMinThrust = configMaxThrust * 0.5f;
else
@@ -1178,7 +1006,7 @@ virtual public void DoConfig(ConfigNode cfg)
}
configThrottle = configMinThrust / configMaxThrust;
if (origMass > 0)
- TLMassMult = MassTL(1.0f);
+ TLMassMult = TechLevels.MassTL(1.0f);
}
// Don't want to change gimbals on TL-enabled engines willy-nilly
// So we don't unless either a transform is specified, or we override.
@@ -1189,11 +1017,11 @@ virtual public void DoConfig(ConfigNode cfg)
{
// allow local override of gimbal mult
if (cfg.HasValue("gimbalMult"))
- gimbal *= float.Parse(cfg.GetValue("gimbalMult"));
+ gimbal *= float.Parse(cfg.GetValue("gimbalMult"), CultureInfo.InvariantCulture);
}
// Cost (multiplier will be 1.0 if unspecified)
- cost = scale * CostTL(cost, cfg);
+ cost = scale * TechLevels.CostTL(cost, cfg);
}
else
{
@@ -1263,97 +1091,6 @@ public void ChangeEngineType(string newEngineType)
SetConfiguration(configuration);
}
- #region TechLevel and Required
- ///
- /// Is this config unlocked? Note: Is the same as CanConfig when not CAREER and no upgrade manager instance.
- ///
- ///
- ///
- public static bool UnlockedConfig(ConfigNode config, Part p)
- {
- if (config == null)
- return false;
- if (!config.HasValue("name"))
- return false;
- if (EntryCostManager.Instance != null && HighLogic.CurrentGame != null && HighLogic.CurrentGame.Mode != Game.Modes.SANDBOX)
- return EntryCostManager.Instance.ConfigUnlocked((RFSettings.Instance.usePartNameInConfigUnlock ? Utilities.GetPartName(p) : string.Empty) + config.GetValue("name"));
- return true;
- }
- public static bool CanConfig(ConfigNode config)
- {
- if (config == null)
- return false;
- if (!config.HasValue("techRequired") || HighLogic.CurrentGame == null)
- return true;
- if (HighLogic.CurrentGame.Mode == Game.Modes.SANDBOX || ResearchAndDevelopment.GetTechnologyState(config.GetValue("techRequired")) == RDTech.State.Available)
- return true;
- return false;
- }
- public static bool UnlockedTL(string tlName, int newTL)
- {
- if (EntryCostManager.Instance != null && HighLogic.CurrentGame != null && HighLogic.CurrentGame.Mode != Game.Modes.SANDBOX)
- return EntryCostManager.Instance.TLUnlocked(tlName) >= newTL;
- return true;
- }
-
- private double ThrustTL(ConfigNode cfg = null)
- {
- if (techLevel != -1 && !engineType.Contains("S"))
- {
- TechLevel oldTL = new TechLevel(), newTL = new TechLevel();
- if (oldTL.Load(cfg ?? config, techNodes, engineType, origTechLevel) &&
- newTL.Load(cfg ?? config, techNodes, engineType, techLevel))
- return newTL.Thrust(oldTL);
- }
- return 1;
- }
-
- private float ThrustTL(float thrust, ConfigNode cfg = null)
- {
- return (float)Math.Round(thrust * ThrustTL(cfg), 6);
- }
-
- private float ThrustTL(string thrust, ConfigNode cfg = null)
- {
- float.TryParse(thrust, out float tmp);
- return ThrustTL(tmp, cfg);
- }
-
- private double MassTL(ConfigNode cfg = null)
- {
- if (techLevel != -1)
- {
- TechLevel oldTL = new TechLevel(), newTL = new TechLevel();
- if (oldTL.Load(cfg ?? config, techNodes, engineType, origTechLevel) &&
- newTL.Load(cfg ?? config, techNodes, engineType, techLevel))
- return newTL.Mass(oldTL, engineType.Contains("S"));
- }
- return 1;
- }
-
- private float MassTL(float mass)
- {
- return (float)Math.Round(mass * MassTL(), 6);
- }
- private float CostTL(float cost, ConfigNode cfg = null)
- {
- TechLevel cTL = new TechLevel();
- TechLevel oTL = new TechLevel();
- if (cTL.Load(cfg, techNodes, engineType, techLevel) && oTL.Load(cfg, techNodes, engineType, origTechLevel) && part.partInfo != null)
- {
- // Bit of a dance: we have to figure out the total cost of the part, but doing so
- // also depends on us. So we zero out our contribution first
- // and then restore configCost.
- float oldCC = configCost;
- configCost = 0f;
- float totalCost = part.partInfo.cost + part.GetModuleCosts(part.partInfo.cost);
- configCost = oldCC;
- cost = (totalCost + cost) * (cTL.CostMult / oTL.CostMult) - totalCost;
- }
-
- return cost;
- }
- #endregion
#endregion
#region GUI
@@ -1364,10 +1101,38 @@ private float CostTL(float cost, ConfigNode cfg = null)
[NonSerialized]
public bool showRFGUI;
+ public static bool userClosedWindow = false;
+
+ // Track the currently open GUI to ensure only one is visible at a time
+ private static ModuleEngineConfigsBase currentlyOpenGUI = null;
+
private void OnPartActionGuiDismiss(Part p)
{
if (p == part || p.isSymmetryCounterPart(part))
+ {
showRFGUI = false;
+ // Clear the currently open GUI tracker if it's this instance
+ if (currentlyOpenGUI == this)
+ currentlyOpenGUI = null;
+ }
+ }
+
+ private void OnPartActionUIShown(UIPartActionWindow window, Part p)
+ {
+ if (p == part && !userClosedWindow)
+ {
+ // Close any previously open GUI before opening this one
+ if (currentlyOpenGUI != null && currentlyOpenGUI != this)
+ {
+ currentlyOpenGUI.showRFGUI = false;
+ }
+
+ showRFGUI = isMaster;
+
+ // Track this as the currently open GUI
+ if (isMaster)
+ currentlyOpenGUI = this;
+ }
}
public override void OnInactive()
@@ -1375,345 +1140,170 @@ public override void OnInactive()
if (!compatible)
return;
if (HighLogic.LoadedSceneIsEditor)
+ {
GameEvents.onPartActionUIDismiss.Remove(OnPartActionGuiDismiss);
+ GameEvents.onPartActionUIShown.Remove(OnPartActionUIShown);
+ }
}
public void OnDestroy()
{
GameEvents.onPartActionUIDismiss.Remove(OnPartActionGuiDismiss);
- }
-
- private static Vector3 mousePos = Vector3.zero;
- private Rect guiWindowRect = new Rect(0, 0, 0, 0);
- private string myToolTip = string.Empty;
- private int counterTT;
- private bool editorLocked = false;
+ GameEvents.onPartActionUIShown.Remove(OnPartActionUIShown);
- private int toolTipWidth => EditorLogic.fetch.editorScreen == EditorScreen.Parts ? 220 : 300;
- private int toolTipHeight => (int)Styles.styleEditorTooltip.CalcHeight(new GUIContent(myToolTip), toolTipWidth);
+ // Note: We don't call EngineConfigTextures.Cleanup() here because textures
+ // are shared across all instances. They'll be cleaned up when Unity unloads the scene.
+ }
- public void OnGUI()
+ // Tech level management - lazy initialization
+ private EngineConfigTechLevels _techLevels;
+ protected EngineConfigTechLevels TechLevels
{
- if (!compatible || !isMaster || !HighLogic.LoadedSceneIsEditor || EditorLogic.fetch == null)
- return;
-
- bool inPartsEditor = EditorLogic.fetch.editorScreen == EditorScreen.Parts;
- if (!(showRFGUI && inPartsEditor) && !(EditorLogic.fetch.editorScreen == EditorScreen.Actions && EditorActionGroups.Instance.GetSelectedParts().Contains(part)))
- {
- EditorUnlock();
- return;
- }
-
- if (inPartsEditor && part.symmetryCounterparts.FirstOrDefault(p => p.persistentId < part.persistentId) is Part)
- return;
-
- if (guiWindowRect.width == 0)
+ get
{
- int posAdd = inPartsEditor ? 256 : 0;
- int posMult = (offsetGUIPos == -1) ? (part.Modules.Contains("ModuleFuelTanks") ? 1 : 0) : offsetGUIPos;
- guiWindowRect = new Rect(posAdd + 430 * posMult, 365, 430, (Screen.height - 365));
+ if (_techLevels == null)
+ _techLevels = new EngineConfigTechLevels(this);
+ return _techLevels;
}
+ }
- mousePos = Input.mousePosition; //Mouse location; based on Kerbal Engineer Redux code
- mousePos.y = Screen.height - mousePos.y;
- if (guiWindowRect.Contains(mousePos))
- EditorLock();
- else
- EditorUnlock();
-
- myToolTip = myToolTip.Trim();
- if (!string.IsNullOrEmpty(myToolTip))
+ // Integration with B9PartSwitch and TestFlight - lazy initialization
+ private EngineConfigIntegrations _integrations;
+ internal EngineConfigIntegrations Integrations
+ {
+ get
{
- int offset = inPartsEditor ? -222 : 440;
- GUI.Label(new Rect(guiWindowRect.xMin + offset, mousePos.y - 5, toolTipWidth, toolTipHeight), myToolTip, Styles.styleEditorTooltip);
+ if (_integrations == null)
+ _integrations = new EngineConfigIntegrations(this);
+ return _integrations;
}
-
- guiWindowRect = GUILayout.Window(unchecked((int)part.persistentId), guiWindowRect, EngineManagerGUI, Localizer.Format("#RF_Engine_WindowTitle", part.partInfo.title), Styles.styleEditorPanel); // "Configure " + part.partInfo.title
}
- private void EditorLock()
+ // GUI rendering - lazy initialization
+ private EngineConfigGUI _gui;
+ private EngineConfigGUI GUI
{
- if (!editorLocked)
+ get
{
- EditorLogic.fetch.Lock(false, false, false, "RFGUILock");
- editorLocked = true;
- KSP.UI.Screens.Editor.PartListTooltipMasterController.Instance?.HideTooltip();
+ if (_gui == null)
+ _gui = new EngineConfigGUI(this);
+ return _gui;
}
}
- private void EditorUnlock()
+ ///
+ /// Struct for passing configuration row data to GUI
+ ///
+ public struct ConfigRowDefinition
{
- if (editorLocked)
- {
- EditorLogic.fetch.Unlock("RFGUILock");
- editorLocked = false;
- }
+ public ConfigNode Node;
+ public string DisplayName;
+ public bool IsSelected;
+ public bool Indent;
+ public Action Apply;
}
- protected string GetCostString(ConfigNode node)
+ ///
+ /// Builds the list of configuration rows to display in the GUI.
+ /// Virtual so derived classes can customize the row structure.
+ ///
+ public virtual IEnumerable BuildConfigRows()
{
- string costString = string.Empty;
- if (node.HasValue("cost"))
+ foreach (var node in FilteredDisplayConfigs(false))
{
- float curCost = scale * float.Parse(node.GetValue("cost"));
-
- if (techLevel != -1)
+ string configName = node.GetValue("name");
+ yield return new ConfigRowDefinition
{
- curCost = CostTL(curCost, node) - CostTL(0f, node); // get purely the config cost difference
- }
- costString = $" ({((curCost < 0) ? string.Empty : "+")}{curCost:N0}f)";
+ Node = node,
+ DisplayName = GetConfigDisplayName(node),
+ IsSelected = configName == configuration,
+ Indent = false,
+ Apply = () => GUIApplyConfig(configName)
+ };
}
- return costString;
}
- /// Normal apply action for the 'select ' button.
- protected void GUIApplyConfig(string configName)
+ ///
+ /// Draws the configuration selector UI elements.
+ /// Virtual so derived classes can add custom UI before the config table.
+ ///
+ protected internal virtual void DrawConfigSelectors(IEnumerable availableConfigNodes)
{
- SetConfiguration(configName, true);
- UpdateSymmetryCounterparts();
- GameEvents.onEditorShipModified.Fire(EditorLogic.fetch.ship);
- MarkWindowDirty();
+ // Default implementation - just draw the table
+ // Derived classes can override to add custom UI
}
- protected void DrawSelectButton(ConfigNode node, bool isSelected, Action apply)
+ ///
+ /// Internal callback for GUI to apply a selected configuration.
+ ///
+ internal void GUIApplyConfig(string configName)
{
- var nName = node.GetValue("name");
- var dispName = GetConfigDisplayName(node);
- var costString = GetCostString(node);
- var configInfo = GetConfigInfo(node);
-
- using (new GUILayout.HorizontalScope())
- {
- // For simulations, RP-1 will allow selecting all configs despite tech status or whether the entry cost has been paid.
- // The KCT that comes with RP-1 will call the Validate() method when the player tries to add a vessel to the build queue.
- if (Utilities.RP1Found)
- {
- // Currently selected.
- if (isSelected)
- {
- GUILayout.Label(new GUIContent($"{Localizer.GetStringByTag("#RF_Engine_CurrentConfig")}: {dispName}{costString}", configInfo)); // Current config
- }
- else if (GUILayout.Button(new GUIContent($"{Localizer.GetStringByTag("#RF_Engine_ConfigSwitch")} {dispName}{costString}", configInfo))) // Switch to
- apply(nName);
-
- if (!UnlockedConfig(node, part))
- {
- double upgradeCost = EntryCostManager.Instance.ConfigEntryCost(nName);
- string techRequired = node.GetValue("techRequired");
- if (upgradeCost <= 0)
- {
- // Auto-buy.
- EntryCostManager.Instance.PurchaseConfig(nName, techRequired);
- }
-
- bool isConfigAvailable = CanConfig(node);
- string tooltip = string.Empty;
- if (!isConfigAvailable && techNameToTitle.TryGetValue(techRequired, out string techStr))
- {
- tooltip = Localizer.Format("#RF_Engine_LacksTech", techStr); // $"Lacks tech for {techStr}"
- }
-
- GUI.enabled = isConfigAvailable;
- if (GUILayout.Button(new GUIContent($"{Localizer.GetStringByTag("#RF_Engine_Purchase")} ({upgradeCost:N0}f)", tooltip), GUILayout.Width(145))) // Purchase
- {
- if (EntryCostManager.Instance.PurchaseConfig(nName, node.GetValue("techRequired")))
- apply(nName);
- }
- GUI.enabled = true;
- }
- }
- else
- {
- // Currently selected.
- if (isSelected)
- {
- GUILayout.Label(new GUIContent($"{Localizer.GetStringByTag("#RF_Engine_CurrentConfig")}: {dispName}{costString}", configInfo)); // Current config
- return;
- }
-
- // Locked.
- if (!CanConfig(node))
- {
- if (techNameToTitle.TryGetValue(node.GetValue("techRequired"), out string techStr))
- techStr = $"\n{Localizer.GetStringByTag("#RF_Engine_Requires")}: " + techStr; // Requires
- GUILayout.Label(new GUIContent(Localizer.Format("#RF_Engine_LacksTech", dispName), configInfo + techStr)); // $"Lacks tech for {dispName}"
- return;
- }
-
- // Available.
- if (UnlockedConfig(node, part))
- {
- if (GUILayout.Button(new GUIContent($"{Localizer.GetStringByTag("#RF_Engine_ConfigSwitch")} {dispName}{costString}", configInfo))) // Switch to
- apply(nName);
- return;
- }
-
- // Purchase.
- double upgradeCost = EntryCostManager.Instance.ConfigEntryCost(nName);
- string techRequired = node.GetValue("techRequired");
- if (upgradeCost > 0d)
- {
- costString = $" ({upgradeCost:N0}f)";
- if (GUILayout.Button(new GUIContent($"{Localizer.GetStringByTag("#RF_Engine_Purchase")} {dispName}{costString}", configInfo))) // Purchase
- {
- if (EntryCostManager.Instance.PurchaseConfig(nName, techRequired))
- apply(nName);
- }
- }
- else
- {
- // Auto-buy.
- EntryCostManager.Instance.PurchaseConfig(nName, techRequired);
- if (GUILayout.Button(new GUIContent($"{Localizer.GetStringByTag("#RF_Engine_ConfigSwitch")} {dispName}{costString}", configInfo))) // Switch to
- apply(nName);
- }
- }
- }
+ SetConfiguration(configName);
+ UpdateSymmetryCounterparts();
}
- virtual protected void DrawConfigSelectors(IEnumerable availableConfigNodes)
+ ///
+ /// Hook point for external mod compatibility (e.g., RP-1 Harmony patches).
+ /// Called by the GUI before rendering each config row to allow external mods
+ /// to track context via Harmony prefix/postfix patches.
+ /// Does not render anything - rendering is handled by EngineConfigGUI.
+ ///
+ internal void DrawSelectButton(ConfigNode node, bool isSelected, Action applyCallback)
{
- foreach (ConfigNode node in availableConfigNodes)
- DrawSelectButton(node, node.GetValue("name") == configuration, GUIApplyConfig);
- }
+ // Hook point for external mods (RP-1) to patch and track tech node context
+ // RP-1's Harmony prefix sets techNode here, and postfix clears it after this method returns
+ // So we must invoke the callback HERE, not after this returns, to keep techNode set
+ string configName = node?.GetValue("name") ?? "null";
- virtual protected void DrawPartInfo()
- {
- // show current info, cost
- if (pModule != null && part.partInfo != null)
- {
- GUILayout.BeginHorizontal();
- string ratedBurnTime = string.Empty;
- if (config.HasValue("ratedBurnTime"))
- {
- if (config.HasValue("ratedContinuousBurnTime"))
- ratedBurnTime = $"{Localizer.GetStringByTag("#RF_Engine_RatedBurnTime")}: {config.GetValue("ratedContinuousBurnTime")}/{config.GetValue("ratedBurnTime")}s\n"; // Rated burn time
- else
- ratedBurnTime = $"{Localizer.GetStringByTag("#RF_Engine_RatedBurnTime")}: {config.GetValue("ratedBurnTime")}s\n"; // Rated burn time
- }
- string label = $"{Localizer.GetStringByTag("#RF_Engine_Enginemass")}: {part.mass:N3}t\n" + // Engine mass
- $"{ratedBurnTime}" +
- $"{pModule.GetInfo()}\n" +
- $"{TLTInfo()}\n" +
- $"{Localizer.GetStringByTag("#RF_Engine_TotalCost")}: {part.partInfo.cost + part.GetModuleCosts(part.partInfo.cost):0}"; // Total cost
- GUILayout.Label(label);
- GUILayout.EndHorizontal();
- }
+ // Invoke the callback while we're still inside this method (before RP-1's postfix clears techNode)
+ applyCallback?.Invoke(configName);
}
- protected void DrawTechLevelSelector()
+ private bool lastShowRFGUI = false;
+
+ public void OnGUI()
{
- // NK Tech Level
- if (techLevel != -1)
+ if (isMaster && HighLogic.LoadedSceneIsEditor)
{
- GUILayout.BeginHorizontal();
-
- GUILayout.Label($"{Localizer.GetStringByTag("#RF_Engine_TechLevel")}: "); // Tech Level
- string minusStr = "X";
- bool canMinus = false;
- if (TechLevel.CanTL(config, techNodes, engineType, techLevel - 1) && techLevel > minTechLevel)
- {
- minusStr = "-";
- canMinus = true;
- }
- if (GUILayout.Button(minusStr) && canMinus)
+ // If the user clicked the PAW button to show the GUI, clear the userClosedWindow flag
+ if (showRFGUI && !lastShowRFGUI)
{
- techLevel--;
- SetConfiguration();
- UpdateSymmetryCounterparts();
- MarkWindowDirty();
- }
- GUILayout.Label(techLevel.ToString());
- string plusStr = "X";
- bool canPlus = false;
- bool canBuy = false;
- string tlName = Utilities.GetPartName(part) + configuration;
- double tlIncrMult = (double)(techLevel + 1 - origTechLevel);
- if (TechLevel.CanTL(config, techNodes, engineType, techLevel + 1) && techLevel < maxTechLevel)
- {
- if (UnlockedTL(tlName, techLevel + 1))
- {
- plusStr = "+";
- canPlus = true;
- }
- else
+ userClosedWindow = false;
+
+ // Close any previously open GUI before opening this one
+ if (currentlyOpenGUI != null && currentlyOpenGUI != this)
{
- double cost = EntryCostManager.Instance.TLEntryCost(tlName) * tlIncrMult;
- double sciCost = EntryCostManager.Instance.TLSciEntryCost(tlName) * tlIncrMult;
- bool autobuy = true;
- plusStr = string.Empty;
- if (cost > 0d)
- {
- plusStr += cost.ToString("N0") + "f";
- autobuy = false;
- canBuy = true;
- }
- if (sciCost > 0d)
- {
- if (cost > 0d)
- plusStr += "/";
- autobuy = false;
- canBuy = true;
- plusStr += sciCost.ToString("N1") + "s";
- }
- if (autobuy)
- {
- // auto-upgrade
- EntryCostManager.Instance.SetTLUnlocked(tlName, techLevel + 1);
- plusStr = "+";
- canPlus = true;
- canBuy = false;
- }
+ currentlyOpenGUI.showRFGUI = false;
}
+
+ // Track this as the currently open GUI
+ currentlyOpenGUI = this;
}
- if (GUILayout.Button(plusStr) && (canPlus || canBuy))
+ // If the user clicked the PAW button to hide the GUI
+ else if (!showRFGUI && lastShowRFGUI)
{
- if (!canBuy || EntryCostManager.Instance.PurchaseTL(tlName, techLevel + 1, tlIncrMult))
- {
- techLevel++;
- SetConfiguration();
- UpdateSymmetryCounterparts();
- MarkWindowDirty();
- }
+ // Clear the currently open GUI tracker if it's this instance
+ if (currentlyOpenGUI == this)
+ currentlyOpenGUI = null;
}
- GUILayout.EndHorizontal();
+
+ lastShowRFGUI = showRFGUI;
+
+ GUI.OnGUI();
}
}
- private void EngineManagerGUI(int WindowID)
+ internal void CloseWindow()
{
- GUILayout.Space(20);
-
- GUILayout.BeginHorizontal();
- GUILayout.Label(EditorDescription);
- GUILayout.EndHorizontal();
-
- DrawConfigSelectors(FilteredDisplayConfigs(false));
-
- DrawTechLevelSelector();
- DrawPartInfo();
-
- if (!myToolTip.Equals(string.Empty) && GUI.tooltip.Equals(string.Empty))
- {
- if (counterTT > 4)
- {
- myToolTip = GUI.tooltip;
- counterTT = 0;
- }
- else
- {
- counterTT++;
- }
- }
- else
- {
- myToolTip = GUI.tooltip;
- counterTT = 0;
- }
+ showRFGUI = false;
+ userClosedWindow = true;
- GUI.DragWindow();
+ // Clear the currently open GUI tracker if it's this instance
+ if (currentlyOpenGUI == this)
+ currentlyOpenGUI = null;
}
+
#endregion
#region Helpers
@@ -1785,7 +1375,7 @@ protected void ConfigSaveLoad()
protected static PartModule GetSpecifiedModule(Part p, string eID, int mIdx, string eType, bool weakType) => GetSpecifiedModules(p, eID, mIdx, eType, weakType).FirstOrDefault();
private static readonly List _searchList = new List();
- protected static List GetSpecifiedModules(Part p, string eID, int mIdx, string eType, bool weakType)
+ internal static List GetSpecifiedModules(Part p, string eID, int mIdx, string eType, bool weakType)
{
int mCount = p.Modules.Count;
int tmpIdx = 0;
@@ -1857,10 +1447,10 @@ public virtual bool Validate(out string validationError, out bool canBeResolved,
ConfigNode node = GetConfigByName(configuration);
- if (UnlockedConfig(node, part)) return true;
+ if (EngineConfigTechLevels.UnlockedConfig(node, part)) return true;
techToResolve = config.GetValue("techRequired");
- if (!CanConfig(node))
+ if (!EngineConfigTechLevels.CanConfig(node))
{
validationError = $"{Localizer.GetStringByTag("#RF_Engine_unlocktech")} {ResearchAndDevelopment.GetTechnologyTitle(techToResolve)}"; // unlock tech
canBeResolved = false;
diff --git a/Source/Engines/UI/ChartMath.cs b/Source/Engines/UI/ChartMath.cs
new file mode 100644
index 00000000..3092e1ab
--- /dev/null
+++ b/Source/Engines/UI/ChartMath.cs
@@ -0,0 +1,410 @@
+using System;
+using UnityEngine;
+
+namespace RealFuels
+{
+ ///
+ /// Mathematical calculations for chart rendering and TestFlight curve evaluation.
+ /// All chart math extracted into static utility methods.
+ ///
+ public static class ChartMath
+ {
+ private const int CurvePoints = 100;
+
+ #region Data Structures
+
+ public struct SurvivalCurveData
+ {
+ public float[] SurvivalProbs;
+ public float[] SurvivalProbsEnd;
+ public float MinSurvivalProb;
+ }
+
+ #endregion
+
+ #region TestFlight Curve Building
+
+ ///
+ /// Build the TestFlight cycle curve exactly as TestFlight_Generic_Engines.cfg does.
+ ///
+ public static FloatCurve BuildTestFlightCycleCurve(float ratedBurnTime, float testedBurnTime, float overburnPenalty, bool hasTestedBurnTime)
+ {
+ FloatCurve curve = new FloatCurve();
+
+ curve.Add(0.00f, 10.00f);
+ curve.Add(5.00f, 1.00f, -0.8f, 0f);
+
+ float rbtCushioned = ratedBurnTime + 5f;
+ curve.Add(rbtCushioned, 1f, 0f, 0f);
+
+ if (hasTestedBurnTime)
+ {
+ float ratedToTestedInterval = testedBurnTime - rbtCushioned;
+ float tbtTransitionSlope = 3.135f / ratedToTestedInterval * (overburnPenalty - 1.0f);
+ curve.Add(testedBurnTime, overburnPenalty, tbtTransitionSlope, tbtTransitionSlope);
+
+ float failTime = testedBurnTime * 2.5f;
+ float tbtToFailInterval = failTime - testedBurnTime;
+ float failInSlope = 1.989f / tbtToFailInterval * (100f - overburnPenalty);
+ curve.Add(failTime, 100f, failInSlope, 0f);
+ }
+ else
+ {
+ float failTime = ratedBurnTime * 2.5f;
+ float rbtToFailInterval = failTime - rbtCushioned;
+ float failInSlope = 292.8f / rbtToFailInterval;
+ curve.Add(failTime, 100f, failInSlope, 0f);
+ }
+
+ return curve;
+ }
+
+ ///
+ /// Creates a TestFlight-style non-linear reliability curve that maps data units to reliability.
+ ///
+ public static FloatCurve CreateReliabilityCurve(float reliabilityStart, float reliabilityEnd)
+ {
+ FloatCurve curve = new FloatCurve();
+
+ float failChanceStart = 1f - reliabilityStart;
+ float failChanceEnd = 1f - reliabilityEnd;
+
+ const float reliabilityMidV = 0.75f;
+ const float reliabilityMidH = 0.4f;
+ const float reliabilityMidTangentWeight = 0.5f;
+ const float maxData = 10000f;
+
+ curve.Add(0f, failChanceStart);
+
+ float key1X = reliabilityMidH * 5000f + 1000f;
+ float key1Y = failChanceStart + reliabilityMidV * (failChanceEnd - failChanceStart);
+
+ float tangentPart1 = (failChanceEnd - failChanceStart) * 0.0001f * reliabilityMidTangentWeight;
+ float linearTangent = (failChanceEnd - key1Y) / (maxData - key1X);
+ float tangentPart2 = linearTangent * (1f - reliabilityMidTangentWeight);
+ float key1Tangent = tangentPart1 + tangentPart2;
+
+ curve.Add(key1X, key1Y, key1Tangent, key1Tangent);
+ curve.Add(maxData, failChanceEnd, 0f, 0f);
+
+ return curve;
+ }
+
+ ///
+ /// Evaluates reliability at a given data value using TestFlight's non-linear curve.
+ ///
+ public static float EvaluateReliabilityAtData(float dataUnits, float reliabilityStart, float reliabilityEnd)
+ {
+ FloatCurve curve = CreateReliabilityCurve(reliabilityStart, reliabilityEnd);
+ float failChance = curve.Evaluate(dataUnits);
+ return 1f - failChance;
+ }
+
+ #endregion
+
+ #region Survival Calculation
+
+ ///
+ /// Calculate survival curves for start and end reliability.
+ ///
+ public static SurvivalCurveData CalculateSurvivalCurves(float cycleReliabilityStart, float cycleReliabilityEnd,
+ float ratedBurnTime, FloatCurve cycleCurve, float maxTime, int clusterSize)
+ {
+ float[] survivalProbsStart = new float[CurvePoints];
+ float[] survivalProbsEnd = new float[CurvePoints];
+ float minSurvivalProb = 1f;
+
+ float baseRateStart = -Mathf.Log(cycleReliabilityStart) / ratedBurnTime;
+ float baseRateEnd = -Mathf.Log(cycleReliabilityEnd) / ratedBurnTime;
+
+ for (int i = 0; i < CurvePoints; i++)
+ {
+ float t = (i / (float)(CurvePoints - 1)) * maxTime;
+
+ survivalProbsStart[i] = CalculateSurvivalProbAtTime(t, ratedBurnTime, cycleReliabilityStart, baseRateStart, cycleCurve);
+ survivalProbsEnd[i] = CalculateSurvivalProbAtTime(t, ratedBurnTime, cycleReliabilityEnd, baseRateEnd, cycleCurve);
+
+ minSurvivalProb = Mathf.Min(minSurvivalProb, survivalProbsStart[i]);
+ minSurvivalProb = Mathf.Min(minSurvivalProb, survivalProbsEnd[i]);
+ }
+
+ // Apply cluster math
+ if (clusterSize > 1)
+ {
+ for (int i = 0; i < CurvePoints; i++)
+ {
+ survivalProbsStart[i] = Mathf.Pow(survivalProbsStart[i], clusterSize);
+ survivalProbsEnd[i] = Mathf.Pow(survivalProbsEnd[i], clusterSize);
+ minSurvivalProb = Mathf.Min(minSurvivalProb, survivalProbsStart[i]);
+ minSurvivalProb = Mathf.Min(minSurvivalProb, survivalProbsEnd[i]);
+ }
+ }
+
+ float yAxisMin = RoundToNiceNumber(Mathf.Max(0f, minSurvivalProb - 0.02f), false);
+
+ return new SurvivalCurveData
+ {
+ SurvivalProbs = survivalProbsStart,
+ SurvivalProbsEnd = survivalProbsEnd,
+ MinSurvivalProb = yAxisMin
+ };
+ }
+
+ ///
+ /// Calculate survival curve for a single reliability value.
+ ///
+ public static SurvivalCurveData CalculateSurvivalCurve(float cycleReliability, float ratedBurnTime,
+ FloatCurve cycleCurve, float maxTime, int clusterSize)
+ {
+ float[] survivalProbs = new float[CurvePoints];
+ float minSurvivalProb = 1f;
+
+ float baseRate = -Mathf.Log(cycleReliability) / ratedBurnTime;
+
+ for (int i = 0; i < CurvePoints; i++)
+ {
+ float t = (i / (float)(CurvePoints - 1)) * maxTime;
+ survivalProbs[i] = CalculateSurvivalProbAtTime(t, ratedBurnTime, cycleReliability, baseRate, cycleCurve);
+ minSurvivalProb = Mathf.Min(minSurvivalProb, survivalProbs[i]);
+ }
+
+ if (clusterSize > 1)
+ {
+ for (int i = 0; i < CurvePoints; i++)
+ {
+ survivalProbs[i] = Mathf.Pow(survivalProbs[i], clusterSize);
+ minSurvivalProb = Mathf.Min(minSurvivalProb, survivalProbs[i]);
+ }
+ }
+
+ return new SurvivalCurveData
+ {
+ SurvivalProbs = survivalProbs,
+ SurvivalProbsEnd = new float[CurvePoints],
+ MinSurvivalProb = minSurvivalProb
+ };
+ }
+
+ ///
+ /// Calculate survival probability at a specific time.
+ ///
+ public static float CalculateSurvivalProbAtTime(float time, float ratedBurnTime,
+ float cycleReliability, float baseRate, FloatCurve cycleCurve)
+ {
+ if (time <= ratedBurnTime)
+ {
+ return Mathf.Pow(cycleReliability, time / ratedBurnTime);
+ }
+ else
+ {
+ float survivalToRated = cycleReliability;
+ float integratedModifier = IntegrateCycleCurve(cycleCurve, ratedBurnTime, time, 20);
+ float additionalFailRate = baseRate * integratedModifier;
+ return Mathf.Clamp01(survivalToRated * Mathf.Exp(-additionalFailRate));
+ }
+ }
+
+ ///
+ /// Numerically integrate the cycle curve from t1 to t2 using trapezoidal rule.
+ ///
+ public static float IntegrateCycleCurve(FloatCurve curve, float t1, float t2, int steps)
+ {
+ if (t2 <= t1) return 0f;
+
+ float dt = (t2 - t1) / steps;
+ float sum = 0f;
+
+ for (int i = 0; i < steps; i++)
+ {
+ float tStart = t1 + i * dt;
+ float tEnd = tStart + dt;
+ float valueStart = curve.Evaluate(tStart);
+ float valueEnd = curve.Evaluate(tEnd);
+ sum += (valueStart + valueEnd) * 0.5f * dt;
+ }
+
+ return sum;
+ }
+
+ #endregion
+
+ #region Coordinate Conversion
+
+ ///
+ /// Convert time to x-position on the chart.
+ ///
+ public static float TimeToXPosition(float time, float maxTime, float plotX, float plotWidth, bool useLogScale)
+ {
+ if (useLogScale)
+ {
+ float logTime = Mathf.Log10(time + 1f);
+ float logMax = Mathf.Log10(maxTime + 1f);
+ return plotX + (logTime / logMax) * plotWidth;
+ }
+ else
+ {
+ return plotX + (time / maxTime) * plotWidth;
+ }
+ }
+
+ ///
+ /// Convert x-position back to time.
+ ///
+ public static float XPositionToTime(float xPos, float maxTime, float plotX, float plotWidth, bool useLogScale)
+ {
+ float normalizedX = (xPos - plotX) / plotWidth;
+ normalizedX = Mathf.Clamp01(normalizedX);
+
+ if (useLogScale)
+ {
+ float logMax = Mathf.Log10(maxTime + 1f);
+ return Mathf.Pow(10f, normalizedX * logMax) - 1f;
+ }
+ else
+ {
+ return normalizedX * maxTime;
+ }
+ }
+
+ ///
+ /// Convert survival probability to y-position on the chart.
+ ///
+ public static float SurvivalProbToYPosition(float survivalProb, float yAxisMin, float plotY, float plotHeight, bool useLogScale)
+ {
+ if (useLogScale)
+ {
+ float logProb = Mathf.Log10(survivalProb + 0.0001f);
+ float logMax = Mathf.Log10(1f + 0.0001f);
+ float logMin = Mathf.Log10(yAxisMin + 0.0001f);
+ float normalizedLog = (logProb - logMin) / (logMax - logMin);
+ return plotY + plotHeight - (normalizedLog * plotHeight);
+ }
+ else
+ {
+ float normalizedSurvival = (survivalProb - yAxisMin) / (1f - yAxisMin);
+ return plotY + plotHeight - (normalizedSurvival * plotHeight);
+ }
+ }
+
+ #endregion
+
+ #region Formatting
+
+ ///
+ /// Format time in seconds to human-readable string (xd xh xm xs).
+ ///
+ public static string FormatTime(float timeInSeconds)
+ {
+ if (timeInSeconds < 0.1f) return "0s";
+
+ int totalSeconds = Mathf.RoundToInt(timeInSeconds);
+ int days = totalSeconds / 86400;
+ int hours = (totalSeconds % 86400) / 3600;
+ int minutes = (totalSeconds % 3600) / 60;
+ int seconds = totalSeconds % 60;
+
+ string result = "";
+ if (days > 0) result += $"{days}d ";
+ if (hours > 0) result += $"{hours}h ";
+ if (minutes > 0) result += $"{minutes}m ";
+ if (seconds > 0 || string.IsNullOrEmpty(result)) result += $"{seconds}s";
+
+ return result.TrimEnd();
+ }
+
+ ///
+ /// Format MTBF (mean time between failures) in human-readable units.
+ ///
+ public static string FormatMTBF(float mtbfSeconds)
+ {
+ if (float.IsInfinity(mtbfSeconds) || float.IsNaN(mtbfSeconds))
+ return "∞";
+
+ if (mtbfSeconds < 60f) return $"{mtbfSeconds:F1}s";
+ if (mtbfSeconds < 3600f) return $"{mtbfSeconds / 60f:F1}m";
+ if (mtbfSeconds < 86400f) return $"{mtbfSeconds / 3600f:F1}h";
+ if (mtbfSeconds < 31536000f) return $"{mtbfSeconds / 86400f:F1}d";
+ return $"{mtbfSeconds / 31536000f:F1}y";
+ }
+
+ #endregion
+
+ #region Drawing Helpers
+
+ ///
+ /// Draw a line between two points with rotation.
+ ///
+ public static void DrawLine(Vector2 start, Vector2 end, Texture2D texture, float width)
+ {
+ if (texture == null) return;
+
+ Vector2 diff = end - start;
+ float angle = Mathf.Atan2(diff.y, diff.x) * Mathf.Rad2Deg;
+ float length = diff.magnitude;
+
+ Matrix4x4 matrixBackup = GUI.matrix;
+ try
+ {
+ GUIUtility.RotateAroundPivot(angle, start);
+ GUI.DrawTexture(new Rect(start.x, start.y - width / 2, length, width), texture);
+ }
+ finally
+ {
+ GUI.matrix = matrixBackup;
+ }
+ }
+
+ ///
+ /// Draw a filled circle.
+ ///
+ public static void DrawCircle(Rect rect, Texture2D texture)
+ {
+ if (texture == null || Event.current.type != EventType.Repaint) return;
+
+ float centerX = rect.x + rect.width / 2f;
+ float centerY = rect.y + rect.height / 2f;
+ float radius = rect.width / 2f;
+
+ for (float r = radius; r > 0; r -= 0.5f)
+ {
+ float size = r * 2f;
+ GUI.DrawTexture(new Rect(centerX - r, centerY - r, size, size), texture);
+ }
+ }
+
+ #endregion
+
+ #region Utilities
+
+ ///
+ /// Round a value to a "nice" number (1, 2, or 5 times a power of 10).
+ ///
+ public static float RoundToNiceNumber(float value, bool roundUp)
+ {
+ if (value <= 0f) return 0f;
+
+ float exponent = Mathf.Floor(Mathf.Log10(value));
+ float fraction = value / Mathf.Pow(10f, exponent);
+
+ float niceFraction;
+ if (roundUp)
+ {
+ if (fraction <= 1f) niceFraction = 1f;
+ else if (fraction <= 2f) niceFraction = 2f;
+ else if (fraction <= 5f) niceFraction = 5f;
+ else niceFraction = 10f;
+ }
+ else
+ {
+ if (fraction < 1.5f) niceFraction = 1f;
+ else if (fraction < 3.5f) niceFraction = 2f;
+ else if (fraction < 7.5f) niceFraction = 5f;
+ else niceFraction = 10f;
+ }
+
+ return niceFraction * Mathf.Pow(10f, exponent);
+ }
+
+ #endregion
+ }
+}
diff --git a/Source/Engines/UI/EngineConfigChart.cs b/Source/Engines/UI/EngineConfigChart.cs
new file mode 100644
index 00000000..9cac06f1
--- /dev/null
+++ b/Source/Engines/UI/EngineConfigChart.cs
@@ -0,0 +1,442 @@
+using System;
+using UnityEngine;
+
+namespace RealFuels
+{
+ ///
+ /// Handles all chart rendering for engine configuration failure probability visualization.
+ /// Displays survival probability curves based on TestFlight data.
+ ///
+ public class EngineConfigChart
+ {
+ private readonly ModuleEngineConfigsBase _module;
+ private readonly EngineConfigTextures _textures;
+
+ // Chart state
+ private bool _useLogScaleX = false;
+ private bool _useLogScaleY = false;
+
+ // Simulation state
+ private bool _useSimulatedData = false;
+ private float _simulatedDataValue = 0f;
+ private int _clusterSize = 1;
+ private string _clusterSizeInput = "1";
+ private string _dataValueInput = "0";
+ private string _sliderTimeInput = "100.0";
+ private bool _includeIgnition = false;
+
+ // Public properties for external access
+ public bool UseLogScaleX { get => _useLogScaleX; set => _useLogScaleX = value; }
+ public bool UseLogScaleY { get => _useLogScaleY; set => _useLogScaleY = value; }
+ public bool UseSimulatedData { get => _useSimulatedData; set => _useSimulatedData = value; }
+ public float SimulatedDataValue { get => _simulatedDataValue; set => _simulatedDataValue = value; }
+ public int ClusterSize { get => _clusterSize; set => _clusterSize = value; }
+ public string ClusterSizeInput { get => _clusterSizeInput; set => _clusterSizeInput = value; }
+ public string DataValueInput { get => _dataValueInput; set => _dataValueInput = value; }
+ public string SliderTimeInput { get => _sliderTimeInput; set => _sliderTimeInput = value; }
+ public bool IncludeIgnition { get => _includeIgnition; set => _includeIgnition = value; }
+
+ public EngineConfigChart(ModuleEngineConfigsBase module)
+ {
+ _module = module;
+ _textures = EngineConfigTextures.Instance;
+ }
+
+ ///
+ /// Draws the failure probability chart and info panel side by side.
+ ///
+ public void Draw(ConfigNode configNode, float width, float height, ref float sliderTime)
+ {
+ _textures.EnsureInitialized();
+ EngineConfigStyles.Initialize();
+
+ // Values are copied to CONFIG level by ModuleManager patch
+ if (!configNode.HasValue("cycleReliabilityStart")) return;
+ if (!configNode.HasValue("cycleReliabilityEnd")) return;
+ if (!float.TryParse(configNode.GetValue("cycleReliabilityStart"), out float cycleReliabilityStart)) return;
+ if (!float.TryParse(configNode.GetValue("cycleReliabilityEnd"), out float cycleReliabilityEnd)) return;
+
+ // Validate reliability is in valid range
+ if (cycleReliabilityStart <= 0f || cycleReliabilityStart > 1f) return;
+ if (cycleReliabilityEnd <= 0f || cycleReliabilityEnd > 1f) return;
+
+ float ratedBurnTime = 0;
+ if (!configNode.TryGetValue("ratedBurnTime", ref ratedBurnTime) || ratedBurnTime <= 0) return;
+
+ float ratedContinuousBurnTime = ratedBurnTime;
+ configNode.TryGetValue("ratedContinuousBurnTime", ref ratedContinuousBurnTime);
+
+ // Skip chart if this is a cumulative-limited engine (continuous << total)
+ if (ratedContinuousBurnTime < ratedBurnTime * 0.9f)
+ {
+ // Display error message for dual burn time configs
+ GUIStyle redCenteredStyle = new GUIStyle(GUI.skin.label)
+ {
+ normal = { textColor = Color.red },
+ alignment = TextAnchor.MiddleCenter,
+ wordWrap = true
+ };
+
+ GUILayout.BeginVertical(GUILayout.Width(width), GUILayout.Height(height));
+ GUILayout.FlexibleSpace();
+ GUILayout.Label("Dual burn time configurations (continuous/cumulative)\nare not supported for reliability charts", redCenteredStyle);
+ GUILayout.FlexibleSpace();
+ GUILayout.EndVertical();
+ return;
+ }
+
+ // Read testedBurnTime to match TestFlight's exact behavior
+ float testedBurnTime = 0f;
+ bool hasTestedBurnTime = configNode.TryGetValue("testedBurnTime", ref testedBurnTime) && testedBurnTime > ratedBurnTime;
+
+ // Split the area: chart on left (58%), info on right (42%)
+ float chartWidth = width * 0.58f;
+ float infoWidth = width * 0.42f;
+
+ float overburnPenalty = 2.0f;
+ configNode.TryGetValue("overburnPenalty", ref overburnPenalty);
+
+ // Build the actual TestFlight cycle curve
+ FloatCurve cycleCurve = ChartMath.BuildTestFlightCycleCurve(ratedBurnTime, testedBurnTime, overburnPenalty, hasTestedBurnTime);
+
+ // Main container
+ Rect containerRect = GUILayoutUtility.GetRect(width, height);
+
+ // Chart area (left side)
+ const float padding = 38f;
+ float plotWidth = chartWidth - padding * 2;
+ float plotHeight = height - padding * 2;
+
+ float maxTime = hasTestedBurnTime ? testedBurnTime * 3.5f : ratedBurnTime * 3.5f;
+
+ Rect chartRect = new Rect(containerRect.x, containerRect.y, chartWidth, height);
+ Rect plotArea = new Rect(chartRect.x + padding, chartRect.y + padding, plotWidth, plotHeight);
+
+ // Info panel area (right side)
+ Rect infoRect = new Rect(containerRect.x + chartWidth, containerRect.y, infoWidth, height);
+
+ // Get ignition reliability values
+ float ignitionReliabilityStart = 1f;
+ float ignitionReliabilityEnd = 1f;
+ configNode.TryGetValue("ignitionReliabilityStart", ref ignitionReliabilityStart);
+ configNode.TryGetValue("ignitionReliabilityEnd", ref ignitionReliabilityEnd);
+
+ // Calculate survival curves
+ var curveData = ChartMath.CalculateSurvivalCurves(
+ cycleReliabilityStart, cycleReliabilityEnd,
+ ratedBurnTime, cycleCurve, maxTime, _clusterSize);
+
+ // Get current data
+ float realCurrentData = TestFlightWrapper.GetCurrentFlightData(_module.part);
+ float realMaxData = TestFlightWrapper.GetMaximumData(_module.part);
+ float currentDataValue = _useSimulatedData ? _simulatedDataValue : realCurrentData;
+ float maxDataValue = realMaxData > 0f ? realMaxData : 10000f;
+ float dataPercentage = (maxDataValue > 0f) ? Mathf.Clamp01(currentDataValue / maxDataValue) : 0f;
+ bool hasCurrentData = (_useSimulatedData && currentDataValue >= 0f) || (realCurrentData >= 0f && realMaxData > 0f);
+
+ float cycleReliabilityCurrent = 0f;
+ ChartMath.SurvivalCurveData currentCurveData = default;
+ float ignitionReliabilityCurrent = 0f;
+
+ if (hasCurrentData)
+ {
+ cycleReliabilityCurrent = ChartMath.EvaluateReliabilityAtData(currentDataValue, cycleReliabilityStart, cycleReliabilityEnd);
+ ignitionReliabilityCurrent = ChartMath.EvaluateReliabilityAtData(currentDataValue, ignitionReliabilityStart, ignitionReliabilityEnd);
+ currentCurveData = ChartMath.CalculateSurvivalCurve(
+ cycleReliabilityCurrent, ratedBurnTime, cycleCurve, maxTime, _clusterSize);
+ }
+
+ // If including ignition, apply ignition reliability to all curves (vertical scaling)
+ if (_includeIgnition)
+ {
+ // Apply cluster math to ignition probabilities
+ float clusteredIgnitionStart = _clusterSize > 1 ? Mathf.Pow(ignitionReliabilityStart, _clusterSize) : ignitionReliabilityStart;
+ float clusteredIgnitionEnd = _clusterSize > 1 ? Mathf.Pow(ignitionReliabilityEnd, _clusterSize) : ignitionReliabilityEnd;
+ float clusteredIgnitionCurrent = hasCurrentData && _clusterSize > 1 ? Mathf.Pow(ignitionReliabilityCurrent, _clusterSize) : ignitionReliabilityCurrent;
+
+ // Apply clustered ignition to start curve
+ for (int i = 0; i < curveData.SurvivalProbs.Length; i++)
+ {
+ curveData.SurvivalProbs[i] *= clusteredIgnitionStart;
+ }
+
+ // Apply clustered ignition to end curve
+ for (int i = 0; i < curveData.SurvivalProbsEnd.Length; i++)
+ {
+ curveData.SurvivalProbsEnd[i] *= clusteredIgnitionEnd;
+ }
+
+ // Apply clustered ignition to current curve if available
+ if (hasCurrentData)
+ {
+ for (int i = 0; i < currentCurveData.SurvivalProbs.Length; i++)
+ {
+ currentCurveData.SurvivalProbs[i] *= clusteredIgnitionCurrent;
+ }
+ }
+
+ // Update min survival prob
+ curveData.MinSurvivalProb = Mathf.Min(
+ curveData.SurvivalProbs[curveData.SurvivalProbs.Length - 1],
+ curveData.SurvivalProbsEnd[curveData.SurvivalProbsEnd.Length - 1]
+ );
+ }
+
+ // Draw chart
+ DrawChartBackground(chartRect);
+ DrawGrid(plotArea, curveData.MinSurvivalProb, maxTime);
+ DrawCurves(plotArea, curveData, currentCurveData, hasCurrentData, maxTime, curveData.MinSurvivalProb);
+ DrawSliderTimeLine(plotArea, sliderTime, maxTime);
+ DrawAxisLabels(chartRect, plotArea, maxTime, curveData.MinSurvivalProb);
+ DrawLegend(plotArea, hasCurrentData);
+
+ // Draw info panel
+ DrawInfoPanel(infoRect, configNode, ratedBurnTime, testedBurnTime, hasTestedBurnTime,
+ cycleReliabilityStart, cycleReliabilityEnd, hasCurrentData, cycleReliabilityCurrent,
+ dataPercentage, currentDataValue, maxDataValue, realCurrentData, realMaxData,
+ cycleCurve, ref sliderTime, maxTime);
+
+ // Sync back slider time input for consistency
+ _sliderTimeInput = $"{sliderTime:F1}";
+ }
+
+ #region Chart Background & Zones
+
+ private void DrawChartBackground(Rect chartRect)
+ {
+ if (Event.current.type == EventType.Repaint)
+ {
+ GUI.DrawTexture(chartRect, _textures.ChartBg);
+ }
+
+ // Chart title
+ GUI.Label(new Rect(chartRect.x, chartRect.y + 4, chartRect.width, 24),
+ "Survival Probability vs Burn Time", EngineConfigStyles.ChartTitle);
+ }
+
+
+ #endregion
+
+ #region Grid & Axes
+
+ private void DrawGrid(Rect plotArea, float yAxisMin, float maxTime)
+ {
+ GUIStyle labelStyle = EngineConfigStyles.GridLabel;
+
+ if (_useLogScaleY)
+ {
+ float[] logValues = { 0.0001f, 0.001f, 0.01f, 0.1f, 1f };
+ foreach (float survivalProb in logValues)
+ {
+ if (survivalProb < yAxisMin) continue;
+ float y = ChartMath.SurvivalProbToYPosition(survivalProb, yAxisMin, plotArea.y, plotArea.height, _useLogScaleY);
+ DrawGridLine(plotArea.x, y, plotArea.width);
+ DrawYAxisLabel(plotArea.x, y, survivalProb);
+ }
+ }
+ else
+ {
+ for (int i = 0; i <= 10; i++)
+ {
+ bool isMajor = (i % 2 == 0);
+ float survivalProb = yAxisMin + (i / 10f) * (1f - yAxisMin);
+ float y = ChartMath.SurvivalProbToYPosition(survivalProb, yAxisMin, plotArea.y, plotArea.height, _useLogScaleY);
+
+ DrawGridLine(plotArea.x, y, plotArea.width, isMajor);
+ if (isMajor) DrawYAxisLabel(plotArea.x, y, survivalProb);
+ }
+ }
+ }
+
+ private void DrawGridLine(float x, float y, float width, bool major = true)
+ {
+ if (Event.current.type != EventType.Repaint) return;
+ Rect lineRect = new Rect(x, y, width, 1);
+ GUI.DrawTexture(lineRect, major ? _textures.ChartGridMajor : _textures.ChartGridMinor);
+ }
+
+ private void DrawYAxisLabel(float x, float y, float survivalProb)
+ {
+ float labelValue = survivalProb * 100f;
+ string label = labelValue < 1f ? $"{labelValue:F2}%" :
+ (labelValue < 10f ? $"{labelValue:F1}%" : $"{labelValue:F0}%");
+ GUI.Label(new Rect(x - 35, y - 10, 30, 20), label, EngineConfigStyles.GridLabel);
+ }
+
+ private void DrawAxisLabels(Rect chartRect, Rect plotArea, float maxTime, float yAxisMin)
+ {
+ // X-axis labels
+ GUIStyle timeStyle = EngineConfigStyles.TimeLabel;
+
+ if (_useLogScaleX)
+ {
+ float[] logTimes = { 0.1f, 1f, 10f, 60f, 300f, 600f, 1800f, 3600f };
+ foreach (float time in logTimes)
+ {
+ if (time > maxTime) break;
+ float x = ChartMath.TimeToXPosition(time, maxTime, plotArea.x, plotArea.width, _useLogScaleX);
+ GUI.Label(new Rect(x - 25, plotArea.y + plotArea.height + 2, 50, 20),
+ ChartMath.FormatTime(time), timeStyle);
+ }
+ }
+ else
+ {
+ for (int i = 0; i <= 4; i++)
+ {
+ float time = (i / 4f) * maxTime;
+ float x = ChartMath.TimeToXPosition(time, maxTime, plotArea.x, plotArea.width, _useLogScaleX);
+ GUI.Label(new Rect(x - 25, plotArea.y + plotArea.height + 2, 50, 20),
+ ChartMath.FormatTime(time), timeStyle);
+ }
+ }
+ }
+
+ #endregion
+
+ #region Curve Drawing
+
+ private void DrawCurves(Rect plotArea, ChartMath.SurvivalCurveData startCurve,
+ ChartMath.SurvivalCurveData currentCurve, bool hasCurrentData,
+ float maxTime, float yAxisMin)
+ {
+ if (Event.current.type != EventType.Repaint) return;
+
+ // Convert to screen positions
+ Vector2[] pointsStart = ConvertToScreenPoints(startCurve.SurvivalProbs, plotArea, maxTime, yAxisMin);
+ Vector2[] pointsEnd = ConvertToScreenPoints(startCurve.SurvivalProbsEnd, plotArea, maxTime, yAxisMin);
+ Vector2[] pointsCurrent = hasCurrentData ? ConvertToScreenPoints(currentCurve.SurvivalProbs, plotArea, maxTime, yAxisMin) : null;
+
+ // Draw curves
+ DrawCurveLine(pointsStart, _textures.ChartOrangeLine, plotArea);
+ DrawCurveLine(pointsEnd, _textures.ChartGreenLine, plotArea);
+ if (hasCurrentData && pointsCurrent != null)
+ DrawCurveLine(pointsCurrent, _textures.ChartBlueLine, plotArea);
+ }
+
+ private Vector2[] ConvertToScreenPoints(float[] survivalProbs, Rect plotArea, float maxTime, float yAxisMin)
+ {
+ int count = survivalProbs.Length;
+ Vector2[] points = new Vector2[count];
+
+ for (int i = 0; i < count; i++)
+ {
+ float t = (i / (float)(count - 1)) * maxTime;
+ float x = ChartMath.TimeToXPosition(t, maxTime, plotArea.x, plotArea.width, _useLogScaleX);
+ float y = ChartMath.SurvivalProbToYPosition(survivalProbs[i], yAxisMin, plotArea.y, plotArea.height, _useLogScaleY);
+
+ if (float.IsNaN(x) || float.IsNaN(y) || float.IsInfinity(x) || float.IsInfinity(y))
+ {
+ x = plotArea.x;
+ y = plotArea.y + plotArea.height;
+ }
+ points[i] = new Vector2(x, y);
+ }
+
+ return points;
+ }
+
+ private void DrawCurveLine(Vector2[] points, Texture2D texture, Rect plotArea)
+ {
+ float plotAreaRight = plotArea.x + plotArea.width;
+
+ for (int i = 0; i < points.Length - 1; i++)
+ {
+ // Skip segments outside plot area
+ if (points[i].x > plotAreaRight && points[i + 1].x > plotAreaRight) continue;
+ if (points[i].x < plotArea.x && points[i + 1].x < plotArea.x) continue;
+
+ ChartMath.DrawLine(points[i], points[i + 1], texture, 2.5f);
+ }
+ }
+
+ #endregion
+
+ #region Slider Time Line
+
+ private void DrawSliderTimeLine(Rect plotArea, float sliderTime, float maxTime)
+ {
+ if (Event.current.type != EventType.Repaint) return;
+
+ float x = ChartMath.TimeToXPosition(sliderTime, maxTime, plotArea.x, plotArea.width, _useLogScaleX);
+
+ // Clamp to plot area
+ if (x < plotArea.x || x > plotArea.x + plotArea.width) return;
+
+ // Draw white vertical line
+ Color whiteTransparent = new Color(1f, 1f, 1f, 0.8f);
+ Texture2D whiteLine = MakeTex(2, 2, whiteTransparent);
+ GUI.DrawTexture(new Rect(x - 1f, plotArea.y, 2f, plotArea.height), whiteLine);
+ }
+
+ private Texture2D MakeTex(int width, int height, Color col)
+ {
+ Color[] pix = new Color[width * height];
+ for (int i = 0; i < pix.Length; i++)
+ pix[i] = col;
+ Texture2D result = new Texture2D(width, height);
+ result.SetPixels(pix);
+ result.Apply();
+ return result;
+ }
+
+ #endregion
+
+ #region Legend
+
+ private void DrawLegend(Rect plotArea, bool hasCurrentData)
+ {
+ GUIStyle legendStyle = EngineConfigStyles.Legend;
+ float legendWidth = 110f;
+ float legendX = plotArea.x + plotArea.width - legendWidth;
+ float legendY = plotArea.y + 5;
+
+ // Orange line for 0 data
+ GUI.DrawTexture(new Rect(legendX, legendY + 7, 15, 3), _textures.ChartOrangeLine);
+ GUI.Label(new Rect(legendX + 18, legendY, 80, 18), "0 Data", legendStyle);
+
+ if (hasCurrentData)
+ {
+ GUI.DrawTexture(new Rect(legendX, legendY + 25, 15, 3), _textures.ChartBlueLine);
+ GUI.Label(new Rect(legendX + 18, legendY + 18, 100, 18), "Current Data", legendStyle);
+
+ GUI.DrawTexture(new Rect(legendX, legendY + 43, 15, 3), _textures.ChartGreenLine);
+ GUI.Label(new Rect(legendX + 18, legendY + 36, 80, 18), "Max Data", legendStyle);
+ }
+ else
+ {
+ GUI.DrawTexture(new Rect(legendX, legendY + 25, 15, 3), _textures.ChartGreenLine);
+ GUI.Label(new Rect(legendX + 18, legendY + 18, 80, 18), "Max Data", legendStyle);
+ }
+ }
+
+ #endregion
+
+
+ #region Info Panel Integration
+
+ private void DrawInfoPanel(Rect rect, ConfigNode configNode, float ratedBurnTime, float testedBurnTime, bool hasTestedBurnTime,
+ float cycleReliabilityStart, float cycleReliabilityEnd, bool hasCurrentData, float cycleReliabilityCurrent,
+ float dataPercentage, float currentDataValue, float maxDataValue, float realCurrentData, float realMaxData,
+ FloatCurve cycleCurve, ref float sliderTime, float maxTime)
+ {
+ float ignitionReliabilityStart = 1f;
+ float ignitionReliabilityEnd = 1f;
+ configNode.TryGetValue("ignitionReliabilityStart", ref ignitionReliabilityStart);
+ configNode.TryGetValue("ignitionReliabilityEnd", ref ignitionReliabilityEnd);
+
+ float ignitionReliabilityCurrent = hasCurrentData ?
+ ChartMath.EvaluateReliabilityAtData(currentDataValue, ignitionReliabilityStart, ignitionReliabilityEnd) : 0f;
+
+ var infoPanel = new EngineConfigInfoPanel(_module);
+ infoPanel.Draw(rect, ratedBurnTime, testedBurnTime, hasTestedBurnTime,
+ cycleReliabilityStart, cycleReliabilityEnd, ignitionReliabilityStart, ignitionReliabilityEnd,
+ hasCurrentData, cycleReliabilityCurrent, ignitionReliabilityCurrent, dataPercentage,
+ currentDataValue, maxDataValue, realCurrentData, realMaxData,
+ ref _useSimulatedData, ref _simulatedDataValue, ref _clusterSize,
+ ref _clusterSizeInput, ref _dataValueInput, ref sliderTime, ref _sliderTimeInput,
+ ref _includeIgnition, cycleCurve, maxTime);
+ }
+
+ #endregion
+ }
+}
diff --git a/Source/Engines/UI/EngineConfigGUI.cs b/Source/Engines/UI/EngineConfigGUI.cs
new file mode 100644
index 00000000..cb8454c0
--- /dev/null
+++ b/Source/Engines/UI/EngineConfigGUI.cs
@@ -0,0 +1,1315 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using System.Reflection;
+using UnityEngine;
+using KSP.Localization;
+using KSP.UI.Screens;
+using RealFuels.TechLevels;
+
+namespace RealFuels
+{
+ ///
+ /// Handles all GUI rendering for ModuleEngineConfigs.
+ /// Manages the configuration selector window, column visibility, tooltips, and user interaction.
+ ///
+ public class EngineConfigGUI
+ {
+ private readonly ModuleEngineConfigsBase _module;
+ private readonly EngineConfigTechLevels _techLevels;
+ private readonly EngineConfigTextures _textures;
+ private EngineConfigChart _chart;
+
+ // RP-1 integration for credit display
+ private static bool _rp1Checked = false;
+ private static Type _unlockCreditHandlerType = null;
+ private static PropertyInfo _unlockCreditInstanceProperty = null;
+ private static PropertyInfo _totalCreditProperty = null;
+ private static double _cachedCredits = 0;
+ private static int _creditCacheFrame = -1;
+
+ // GUI state
+ private static Vector3 mousePos = Vector3.zero;
+ private static Rect guiWindowRect = new Rect(0, 0, 0, 0);
+ private static uint lastPartId = 0;
+ private static int lastConfigCount = 0;
+ private static bool lastCompactView = false;
+ private static bool lastHasChart = false;
+ private static bool lastShowBottomSection = true;
+ private string myToolTip = string.Empty;
+ private int counterTT;
+ private bool editorLocked = false;
+
+ private Vector2 configScrollPos = Vector2.zero;
+ private GUIContent configGuiContent;
+ private static bool compactView = true; // Default to compact view
+ private bool useLogScaleX = false;
+ private bool useLogScaleY = false;
+ private static bool showBottomSection = true;
+
+ // Column visibility customization
+ private bool showColumnMenu = false;
+ private static Rect columnMenuRect = new Rect(100, 100, 220, 500);
+ private static bool[] columnsVisibleFull = new bool[18];
+ private static bool[] columnsVisibleCompact = new bool[18];
+ private static bool columnVisibilityInitialized = false;
+
+ // Simulation controls
+ private bool useSimulatedData = false;
+ private float simulatedDataValue = 0f;
+ private int clusterSize = 1;
+ private string clusterSizeInput = "1";
+ private string dataValueInput = "0";
+ private float sliderTime = 100f;
+ private string sliderTimeInput = "100.0";
+ private bool includeIgnition = false;
+
+ private const int ConfigRowHeight = 22;
+ private const int ConfigMaxVisibleRows = 16;
+ private float[] ConfigColumnWidths = new float[18];
+
+ private int toolTipWidth => EditorLogic.fetch.editorScreen == EditorScreen.Parts ? 320 : 380;
+
+ public EngineConfigGUI(ModuleEngineConfigsBase module)
+ {
+ _module = module;
+ _techLevels = new EngineConfigTechLevels(module);
+ _textures = EngineConfigTextures.Instance;
+ }
+
+ private EngineConfigChart Chart
+ {
+ get
+ {
+ if (_chart == null)
+ {
+ _chart = new EngineConfigChart(_module);
+ _chart.UseLogScaleX = useLogScaleX;
+ _chart.UseLogScaleY = useLogScaleY;
+ _chart.UseSimulatedData = useSimulatedData;
+ _chart.SimulatedDataValue = simulatedDataValue;
+ _chart.ClusterSize = clusterSize;
+ }
+ return _chart;
+ }
+ }
+
+ #region Main GUI Entry Point
+
+ public void OnGUI()
+ {
+ if (!_module.compatible || !_module.isMaster || !HighLogic.LoadedSceneIsEditor || EditorLogic.fetch == null)
+ return;
+
+ bool inPartsEditor = EditorLogic.fetch.editorScreen == EditorScreen.Parts;
+ if (!(_module.showRFGUI && inPartsEditor) && !(EditorLogic.fetch.editorScreen == EditorScreen.Actions && EditorActionGroups.Instance.GetSelectedParts().Contains(_module.part)))
+ {
+ EditorUnlock();
+ return;
+ }
+
+ if (inPartsEditor && _module.part.symmetryCounterparts.FirstOrDefault(p => p.persistentId < _module.part.persistentId) is Part)
+ return;
+
+ if (guiWindowRect.width == 0)
+ {
+ int posAdd = inPartsEditor ? 256 : 0;
+ int posMult = (_module.offsetGUIPos == -1) ? (_module.part.Modules.Contains("ModuleFuelTanks") ? 1 : 0) : _module.offsetGUIPos;
+ guiWindowRect = new Rect(posAdd + 430 * posMult, 365, 100, 100);
+ }
+
+ uint currentPartId = _module.part.persistentId;
+ int currentConfigCount = _module.FilteredDisplayConfigs(false).Count;
+ bool currentHasChart = _module.config != null && _module.config.HasValue("cycleReliabilityStart");
+ bool contentChanged = currentPartId != lastPartId
+ || currentConfigCount != lastConfigCount
+ || compactView != lastCompactView
+ || currentHasChart != lastHasChart
+ || showBottomSection != lastShowBottomSection;
+
+ if (contentChanged)
+ {
+ float savedX = guiWindowRect.x;
+ float savedY = guiWindowRect.y;
+ float savedWidth = guiWindowRect.width;
+ guiWindowRect = new Rect(savedX, savedY, savedWidth, 100);
+
+ lastPartId = currentPartId;
+ lastConfigCount = currentConfigCount;
+ lastCompactView = compactView;
+ lastHasChart = currentHasChart;
+ lastShowBottomSection = showBottomSection;
+ }
+
+ mousePos = Input.mousePosition;
+ mousePos.y = Screen.height - mousePos.y;
+ if (guiWindowRect.Contains(mousePos))
+ EditorLock();
+ else
+ EditorUnlock();
+
+ myToolTip = myToolTip.Trim();
+
+ guiWindowRect = GUILayout.Window(unchecked((int)_module.part.persistentId), guiWindowRect, EngineManagerGUI, Localizer.Format("#RF_Engine_WindowTitle", _module.part.partInfo.title), Styles.styleEditorPanel);
+
+ if (showColumnMenu)
+ {
+ columnMenuRect = GUI.Window(unchecked((int)_module.part.persistentId) + 1, columnMenuRect, DrawColumnMenuWindow, "Column Settings", Styles.styleEditorPanel);
+ }
+
+ // Draw tooltip AFTER all windows to ensure it appears on top
+ if (!string.IsNullOrEmpty(myToolTip))
+ {
+ // Check if this is a button tooltip (marked with [BTN])
+ bool isButtonTooltip = myToolTip.StartsWith("[BTN]");
+ string displayText = isButtonTooltip ? myToolTip.Substring(5) : myToolTip;
+
+ var tooltipStyle = new GUIStyle(EngineConfigStyles.ChartTooltip)
+ {
+ fontSize = 13,
+ wordWrap = false, // Disable word wrap for button tooltips to get natural width
+ normal = { background = _textures.ChartTooltipBg }
+ };
+
+ var content = new GUIContent(displayText);
+
+ // Calculate dynamic width based on content
+ float actualTooltipWidth;
+ float tooltipHeight;
+
+ if (isButtonTooltip)
+ {
+ // For button tooltips: use natural width of content with some padding
+ Vector2 contentSize = tooltipStyle.CalcSize(content);
+ actualTooltipWidth = Mathf.Min(contentSize.x + 20, 400); // Max 400px width
+ tooltipStyle.wordWrap = actualTooltipWidth >= 400; // Enable wrap only if we hit max width
+ tooltipHeight = tooltipStyle.CalcHeight(content, actualTooltipWidth);
+ }
+ else
+ {
+ // For row tooltips: use fixed width with word wrap
+ tooltipStyle.wordWrap = true;
+ actualTooltipWidth = toolTipWidth;
+ tooltipHeight = tooltipStyle.CalcHeight(content, actualTooltipWidth);
+ }
+
+ // Position button tooltips near cursor, row tooltips at fixed offset
+ float tooltipX, tooltipY;
+ if (isButtonTooltip)
+ {
+ // Position near cursor: to the right and slightly down
+ tooltipX = mousePos.x + 20;
+ tooltipY = mousePos.y + 10;
+
+ // Keep tooltip on screen
+ if (tooltipX + actualTooltipWidth > Screen.width)
+ tooltipX = mousePos.x - actualTooltipWidth - 10; // Show to the left instead
+ if (tooltipY + tooltipHeight > Screen.height)
+ tooltipY = Screen.height - tooltipHeight - 10;
+ }
+ else
+ {
+ // Original positioning for row tooltips
+ int offset = inPartsEditor ? -330 : 440;
+ tooltipX = guiWindowRect.xMin + offset;
+ tooltipY = mousePos.y - 5;
+ }
+
+ // Draw tooltip with maximum priority depth (most negative = on top)
+ int oldDepth = GUI.depth;
+ GUI.depth = -100000; // Use very negative depth to ensure it's on top of everything
+ GUI.Box(new Rect(tooltipX, tooltipY, actualTooltipWidth, tooltipHeight), displayText, tooltipStyle);
+ GUI.depth = oldDepth;
+ }
+ }
+
+ #endregion
+
+ #region GUI Windows
+
+ private void EngineManagerGUI(int WindowID)
+ {
+ GUILayout.BeginVertical(GUILayout.ExpandHeight(false));
+ GUILayout.Space(12); // Increased spacing to prevent overlap with window title
+
+ GUILayout.BeginHorizontal();
+ var descStyle = new GUIStyle(GUI.skin.label)
+ {
+ padding = new RectOffset(0, 0, 0, 0),
+ margin = new RectOffset(0, 0, 0, 0),
+ normal = { textColor = Color.white } // Make description text white
+ };
+ GUILayout.Label(_module.EditorDescription, descStyle);
+ GUILayout.FlexibleSpace();
+ if (GUILayout.Button(compactView ? "Full View" : "Compact View", GUILayout.Width(100)))
+ {
+ compactView = !compactView;
+ }
+ if (GUILayout.Button(showBottomSection ? "Hide Chart" : "Show Chart", GUILayout.Width(85)))
+ {
+ showBottomSection = !showBottomSection;
+ }
+ if (GUILayout.Button("Settings", GUILayout.Width(70)))
+ {
+ showColumnMenu = !showColumnMenu;
+ }
+ // Close button
+ var closeButtonStyle = new GUIStyle(GUI.skin.button)
+ {
+ normal = { textColor = new Color(1f, 0.4f, 0.4f) },
+ hover = { textColor = new Color(1f, 0.2f, 0.2f) },
+ fontStyle = FontStyle.Bold,
+ fontSize = 14
+ };
+ if (GUILayout.Button("✕", closeButtonStyle, GUILayout.Width(25)))
+ {
+ _module.CloseWindow();
+ return;
+ }
+ GUILayout.EndHorizontal();
+
+ GUILayout.Space(7);
+ DrawConfigSelectors(_module.FilteredDisplayConfigs(false));
+
+ if (showBottomSection)
+ {
+ if (_module.config != null && _module.config.HasValue("cycleReliabilityStart"))
+ {
+ GUILayout.Space(6);
+
+ Chart.UseLogScaleX = useLogScaleX;
+ Chart.UseLogScaleY = useLogScaleY;
+ Chart.UseSimulatedData = useSimulatedData;
+ Chart.SimulatedDataValue = simulatedDataValue;
+ Chart.ClusterSize = clusterSize;
+ Chart.ClusterSizeInput = clusterSizeInput;
+ Chart.DataValueInput = dataValueInput;
+ Chart.SliderTimeInput = sliderTimeInput;
+ Chart.IncludeIgnition = includeIgnition;
+
+ Chart.Draw(_module.config, guiWindowRect.width - 10, 375, ref sliderTime);
+
+ useLogScaleX = Chart.UseLogScaleX;
+ useLogScaleY = Chart.UseLogScaleY;
+ useSimulatedData = Chart.UseSimulatedData;
+ simulatedDataValue = Chart.SimulatedDataValue;
+ clusterSize = Chart.ClusterSize;
+ clusterSizeInput = Chart.ClusterSizeInput;
+ dataValueInput = Chart.DataValueInput;
+ sliderTimeInput = Chart.SliderTimeInput;
+ includeIgnition = Chart.IncludeIgnition;
+
+ GUILayout.Space(6);
+ }
+
+ _techLevels.DrawTechLevelSelector();
+ }
+
+ GUILayout.Space(4);
+ GUILayout.EndVertical();
+
+ if (!myToolTip.Equals(string.Empty) && GUI.tooltip.Equals(string.Empty))
+ {
+ if (counterTT > 4)
+ {
+ myToolTip = GUI.tooltip;
+ counterTT = 0;
+ }
+ else
+ {
+ counterTT++;
+ }
+ }
+ else
+ {
+ myToolTip = GUI.tooltip;
+ counterTT = 0;
+ }
+
+ GUI.DragWindow();
+ }
+
+ private void DrawColumnMenuWindow(int windowID)
+ {
+ // Close button in top right
+ var closeButtonStyle = new GUIStyle(GUI.skin.button)
+ {
+ normal = { textColor = new Color(1f, 0.4f, 0.4f) },
+ hover = { textColor = new Color(1f, 0.2f, 0.2f) },
+ fontStyle = FontStyle.Bold,
+ fontSize = 14
+ };
+ if (GUI.Button(new Rect(columnMenuRect.width - 29, 4, 25, 20), "✕", closeButtonStyle))
+ {
+ showColumnMenu = false;
+ return;
+ }
+
+ DrawColumnMenu(new Rect(0, 20, columnMenuRect.width, columnMenuRect.height - 20));
+ GUI.DragWindow(); // Allow dragging from anywhere in the window
+ }
+
+ #endregion
+
+ #region Config Table Drawing
+
+ protected void DrawConfigSelectors(IEnumerable availableConfigNodes)
+ {
+ // Allow derived module classes to add custom UI before the config table
+ _module.DrawConfigSelectors(availableConfigNodes);
+
+ // Then draw the standard config table
+ DrawConfigTable(_module.BuildConfigRows());
+ }
+
+ protected void DrawConfigTable(IEnumerable rows)
+ {
+ EnsureTexturesAndStyles();
+
+ var rowList = rows.ToList();
+ CalculateColumnWidths(rowList);
+
+ float totalWidth = 0f;
+ for (int i = 0; i < ConfigColumnWidths.Length; i++)
+ {
+ if (IsColumnVisible(i))
+ totalWidth += ConfigColumnWidths[i];
+ }
+
+ float requiredWindowWidth = totalWidth + 10f;
+ const float minWindowWidth = 900f;
+ const float minWindowWidthCompact = 550f;
+ // Only enforce full minimum width when bottom section is visible (chart needs the width)
+ // Use smaller minimum for compact view
+ guiWindowRect.width = showBottomSection
+ ? Mathf.Max(requiredWindowWidth, minWindowWidth)
+ : Mathf.Max(requiredWindowWidth, minWindowWidthCompact);
+
+ Rect headerRowRect = GUILayoutUtility.GetRect(GUIContent.none, GUI.skin.label, GUILayout.Height(45));
+ float headerStartX = headerRowRect.x;
+ DrawHeaderRow(new Rect(headerStartX, headerRowRect.y, totalWidth, headerRowRect.height));
+
+ int actualRows = rowList.Count;
+ int visibleRows = Mathf.Min(actualRows, ConfigMaxVisibleRows);
+ int scrollViewHeight = visibleRows * ConfigRowHeight;
+
+ var scrollStyle = new GUIStyle(GUI.skin.scrollView) { padding = new RectOffset(0, 0, 0, 0) };
+ configScrollPos = GUILayout.BeginScrollView(configScrollPos, false, false, GUIStyle.none, GUI.skin.verticalScrollbar, scrollStyle, GUILayout.Height(scrollViewHeight));
+
+ var noSpaceStyle = new GUIStyle { margin = new RectOffset(0, 0, 0, 0), padding = new RectOffset(0, 0, 0, 0) };
+
+ int rowIndex = 0;
+ foreach (var row in rowList)
+ {
+ Rect rowRect = GUILayoutUtility.GetRect(GUIContent.none, noSpaceStyle, GUILayout.Height(ConfigRowHeight));
+ float rowStartX = rowRect.x;
+ Rect tableRowRect = new Rect(rowStartX, rowRect.y, totalWidth, rowRect.height);
+ bool isHovered = tableRowRect.Contains(Event.current.mousePosition);
+
+ bool isLocked = !EngineConfigTechLevels.CanConfig(row.Node);
+ if (Event.current.type == EventType.Repaint)
+ {
+ if (!row.IsSelected && !isLocked && !isHovered && rowIndex % 2 == 1)
+ {
+ GUI.DrawTexture(tableRowRect, _textures.ZebraStripe);
+ }
+
+ if (row.IsSelected)
+ GUI.DrawTexture(tableRowRect, _textures.RowCurrent);
+ else if (isLocked)
+ GUI.DrawTexture(tableRowRect, _textures.RowLocked);
+ else if (isHovered)
+ GUI.DrawTexture(tableRowRect, _textures.RowHover);
+ }
+
+ string tooltip = GetRowTooltip(row.Node);
+ if (configGuiContent == null)
+ configGuiContent = new GUIContent();
+ configGuiContent.text = string.Empty;
+ configGuiContent.tooltip = tooltip;
+ GUI.Label(tableRowRect, configGuiContent, GUIStyle.none);
+
+ // Call DrawSelectButton as a hook point for external mod compatibility (RP-1)
+ // Pass null callback - we don't want to invoke anything during rendering, only during button clicks
+ _module.DrawSelectButton(row.Node, row.IsSelected, null);
+
+ DrawConfigRow(tableRowRect, row, isHovered, isLocked);
+
+ if (Event.current.type == EventType.Repaint)
+ {
+ DrawColumnSeparators(tableRowRect);
+ }
+
+ rowIndex++;
+ }
+
+ GUILayout.EndScrollView();
+ }
+
+ private void DrawHeaderRow(Rect headerRect)
+ {
+ float currentX = headerRect.x;
+ string[] headers = {
+ "Name", Localizer.GetStringByTag("#RF_EngineRF_Thrust"), "Min%",
+ Localizer.GetStringByTag("#RF_Engine_Isp"), Localizer.GetStringByTag("#RF_Engine_Enginemass"),
+ Localizer.GetStringByTag("#RF_Engine_TLTInfo_Gimbal"), Localizer.GetStringByTag("#RF_EngineRF_Ignitions"),
+ Localizer.GetStringByTag("#RF_Engine_ullage"), Localizer.GetStringByTag("#RF_Engine_pressureFed"),
+ "Rated (s)", "Tested (s)", "Ign Reliability", "Burn No Data", "Burn Max Data",
+ "Survival @ Time",
+ Localizer.GetStringByTag("#RF_Engine_Requires"), "Extra Cost", ""
+ };
+ string[] tooltips = {
+ "Configuration name", "Rated thrust", "Minimum throttle",
+ "Sea level and vacuum Isp", "Engine mass", "Gimbal range", "Ignitions",
+ "Ullage requirement", "Pressure-fed", "Rated burn time",
+ "Tested burn time (real-world test duration)",
+ "Ignition reliability (starting / max data)",
+ "Cycle reliability at 0 data", "Cycle reliability at max data",
+ "Survival probability at slider time (starting / max data)",
+ "Required technology", "Extra cost for this config", "Switch and purchase actions"
+ };
+
+ for (int i = 0; i < headers.Length; i++)
+ {
+ if (IsColumnVisible(i))
+ {
+ DrawHeaderCell(new Rect(currentX, headerRect.y, ConfigColumnWidths[i], headerRect.height), headers[i], tooltips[i]);
+ currentX += ConfigColumnWidths[i];
+ }
+ }
+ }
+
+ private void DrawHeaderCell(Rect rect, string text, string tooltip)
+ {
+ bool hover = rect.Contains(Event.current.mousePosition);
+ GUIStyle headerStyle = hover ? EngineConfigStyles.HeaderCellHover : EngineConfigStyles.HeaderCell;
+
+ if (configGuiContent == null)
+ configGuiContent = new GUIContent();
+ configGuiContent.text = text;
+ configGuiContent.tooltip = tooltip;
+ Matrix4x4 matrixBackup = GUI.matrix;
+ float offsetX = rect.width / 2f;
+ Vector2 pivot = new Vector2(rect.x + offsetX, rect.y + rect.height + 4f);
+ GUIUtility.RotateAroundPivot(-45f, pivot);
+ GUI.Label(new Rect(rect.x + offsetX, rect.y + rect.height - 22f, 140f, 24f), configGuiContent, headerStyle);
+ GUI.matrix = matrixBackup;
+ }
+
+ private void DrawConfigRow(Rect rowRect, ModuleEngineConfigsBase.ConfigRowDefinition row, bool isHovered, bool isLocked)
+ {
+ GUIStyle primaryStyle;
+ if (isLocked)
+ primaryStyle = EngineConfigStyles.RowPrimaryLocked;
+ else if (isHovered)
+ primaryStyle = EngineConfigStyles.RowPrimaryHover;
+ else
+ primaryStyle = EngineConfigStyles.RowPrimary;
+
+ GUIStyle secondaryStyle = EngineConfigStyles.RowSecondary;
+
+ float currentX = rowRect.x;
+ string nameText = row.DisplayName;
+ if (row.Indent) nameText = " ↳ " + nameText;
+
+ Action drawCell = (index, text) =>
+ {
+ if (IsColumnVisible(index))
+ {
+ GUI.Label(new Rect(currentX, rowRect.y, ConfigColumnWidths[index], rowRect.height), text, index == 0 ? primaryStyle : secondaryStyle);
+ currentX += ConfigColumnWidths[index];
+ }
+ };
+
+ drawCell(0, nameText);
+ drawCell(1, GetThrustString(row.Node));
+ drawCell(2, GetMinThrottleString(row.Node));
+ drawCell(3, GetIspString(row.Node));
+ drawCell(4, GetMassString(row.Node));
+ drawCell(5, GetGimbalString(row.Node));
+ drawCell(6, GetIgnitionsString(row.Node));
+ drawCell(7, GetBoolSymbol(row.Node, "ullage"));
+ drawCell(8, GetBoolSymbol(row.Node, "pressureFed"));
+ drawCell(9, GetRatedBurnTimeString(row.Node));
+ drawCell(10, GetTestedBurnTimeString(row.Node));
+ drawCell(11, GetIgnitionReliabilityString(row.Node));
+ drawCell(12, GetCycleReliabilityStartString(row.Node));
+ drawCell(13, GetCycleReliabilityEndString(row.Node));
+ drawCell(14, GetSurvivalAtTimeString(row.Node));
+ drawCell(15, GetTechString(row.Node));
+ drawCell(16, GetCostDeltaString(row.Node));
+
+ if (IsColumnVisible(17))
+ {
+ DrawActionCell(new Rect(currentX, rowRect.y + 1, ConfigColumnWidths[17], rowRect.height - 2), row.Node, row.IsSelected, row.Apply);
+ }
+ }
+
+ private void DrawActionCell(Rect rect, ConfigNode node, bool isSelected, Action apply)
+ {
+ GUIStyle smallButtonStyle = GUI.skin.button;
+
+ string configName = node.GetValue("name");
+ bool canUse = EngineConfigTechLevels.CanConfig(node);
+ bool unlocked = EngineConfigTechLevels.UnlockedConfig(node, _module.part);
+ double cost = EntryCostManager.Instance.ConfigEntryCost(configName);
+
+ if (cost <= 0 && !unlocked && canUse)
+ EntryCostManager.Instance.PurchaseConfig(configName, node.GetValue("techRequired"));
+
+ // Calculate button widths dynamically based on their labels
+ string switchLabel = isSelected ? "Active" : "Switch";
+ float switchWidth = smallButtonStyle.CalcSize(new GUIContent(switchLabel)).x + 10f; // Add padding
+
+ GUI.enabled = canUse && !unlocked && cost > 0;
+ string purchaseLabel;
+ string purchaseTooltip = string.Empty;
+
+ if (cost > 0)
+ {
+ // Check if we can use credits to reduce the cost
+ double displayCost = cost;
+ if (!unlocked && TryGetCreditAdjustedCost(cost, out double creditsAvailable, out double costAfterCredits))
+ {
+ displayCost = costAfterCredits;
+ double creditsUsed = cost - costAfterCredits;
+ // Use special marker [BTN] so we can position this tooltip near cursor
+ purchaseTooltip = $"[BTN]Entry Cost: {cost:N0}√\n" +
+ $"Credits Available: {creditsAvailable:N0}\n" +
+ $"Credits Used: {creditsUsed:N0}\n" +
+ $"Final Cost: {costAfterCredits:N0}√";
+ }
+
+ // Show final cost after credits on the button
+ purchaseLabel = unlocked ? "Owned" : $"Buy ({displayCost:N0}√)";
+ }
+ else
+ purchaseLabel = unlocked ? "Owned" : "Free";
+
+ float purchaseWidth = smallButtonStyle.CalcSize(new GUIContent(purchaseLabel)).x + 10f;
+
+ // Position buttons: Switch on left, Purchase on right
+ Rect switchRect = new Rect(rect.x, rect.y, switchWidth, rect.height);
+ Rect purchaseRect = new Rect(rect.x + switchWidth + 4f, rect.y, purchaseWidth, rect.height);
+
+ GUI.enabled = !isSelected;
+ if (GUI.Button(switchRect, switchLabel, smallButtonStyle))
+ {
+ if (!unlocked && cost <= 0)
+ {
+ // Auto-purchase free configs using DrawSelectButton callback
+ _module.DrawSelectButton(node, isSelected, (cfgName) =>
+ {
+ EntryCostManager.Instance.PurchaseConfig(cfgName, node.GetValue("techRequired"));
+ });
+ }
+ apply?.Invoke();
+ }
+
+ GUI.enabled = canUse && !unlocked && cost > 0;
+ if (GUI.Button(purchaseRect, new GUIContent(purchaseLabel, purchaseTooltip), smallButtonStyle))
+ {
+ // Call DrawSelectButton with PurchaseConfig as the callback
+ // This ensures PurchaseConfig runs INSIDE DrawSelectButton (before RP-1's postfix clears techNode)
+ // RP-1's Harmony patches: Prefix sets techNode -> DrawSelectButton body (with callback) -> Postfix clears techNode
+ _module.DrawSelectButton(node, isSelected, (cfgName) =>
+ {
+ if (EntryCostManager.Instance.PurchaseConfig(cfgName, node.GetValue("techRequired")))
+ apply?.Invoke();
+ });
+ }
+
+ GUI.enabled = true;
+ }
+
+ private void DrawColumnSeparators(Rect rowRect)
+ {
+ float currentX = rowRect.x;
+ for (int i = 0; i < ConfigColumnWidths.Length - 1; i++)
+ {
+ if (IsColumnVisible(i))
+ {
+ currentX += ConfigColumnWidths[i];
+ Rect separatorRect = new Rect(currentX, rowRect.y, 1, rowRect.height);
+ GUI.DrawTexture(separatorRect, _textures.ColumnSeparator);
+ }
+ }
+ }
+
+ #endregion
+
+ #region Column Management
+
+ private void DrawColumnMenu(Rect menuRect)
+ {
+ InitializeColumnVisibility();
+
+ string[] columnNames = {
+ "Name", "Thrust", "Min%", "ISP", "Mass", "Gimbal",
+ "Ignitions", "Ullage", "Press-Fed", "Rated (s)", "Tested (s)",
+ "Ign Rel.", "Burn No Data", "Burn Max Data",
+ "Survival", "Tech", "Cost", "Actions"
+ };
+
+ float yPos = menuRect.y + 5;
+ float leftX = menuRect.x + 8;
+
+ GUIStyle headerStyle = new GUIStyle(GUI.skin.label)
+ {
+ fontSize = 11,
+ fontStyle = FontStyle.Bold,
+ normal = { textColor = new Color(0.9f, 0.9f, 0.9f) }
+ };
+ GUIStyle labelStyle = new GUIStyle(GUI.skin.label)
+ {
+ fontSize = 11,
+ normal = { textColor = new Color(0.85f, 0.85f, 0.85f) }
+ };
+
+ GUI.Label(new Rect(leftX + 80, yPos, 50, 16), "Full", headerStyle);
+ GUI.Label(new Rect(leftX + 135, yPos, 60, 16), "Compact", headerStyle);
+ yPos += 18;
+
+ Rect scrollRect = new Rect(leftX, yPos, menuRect.width - 16, menuRect.height - 28);
+
+ GUI.BeginGroup(scrollRect);
+ float itemY = 0;
+
+ for (int i = 0; i < columnNames.Length; i++)
+ {
+ GUI.Label(new Rect(0, itemY, 75, 18), columnNames[i], labelStyle);
+
+ bool newFullVisible = GUI.Toggle(new Rect(85, itemY + 1, 18, 18), columnsVisibleFull[i], "");
+ if (newFullVisible != columnsVisibleFull[i])
+ {
+ columnsVisibleFull[i] = newFullVisible;
+ }
+
+ bool newCompactVisible = GUI.Toggle(new Rect(140, itemY + 1, 18, 18), columnsVisibleCompact[i], "");
+ if (newCompactVisible != columnsVisibleCompact[i])
+ {
+ columnsVisibleCompact[i] = newCompactVisible;
+ }
+
+ itemY += 20;
+ }
+
+ GUI.EndGroup();
+ }
+
+ private void InitializeColumnVisibility()
+ {
+ if (columnVisibilityInitialized)
+ return;
+
+ // Full view: all columns visible
+ for (int i = 0; i < 18; i++)
+ columnsVisibleFull[i] = true;
+
+ // Compact view: Name, Thrust, ISP, Mass, Ignitions, Ullage, Press-Fed, Ign Rel., Survival, Tech, Cost, Actions
+ for (int i = 0; i < 18; i++)
+ columnsVisibleCompact[i] = false;
+
+ int[] compactColumns = { 0, 1, 3, 4, 6, 7, 8, 11, 14, 15, 16, 17 };
+ foreach (int col in compactColumns)
+ columnsVisibleCompact[col] = true;
+
+ columnVisibilityInitialized = true;
+ }
+
+ private bool IsColumnVisible(int columnIndex)
+ {
+ InitializeColumnVisibility();
+
+ if (columnIndex < 0 || columnIndex >= 18)
+ return false;
+
+ return compactView ? columnsVisibleCompact[columnIndex] : columnsVisibleFull[columnIndex];
+ }
+
+ private void CalculateColumnWidths(List rows)
+ {
+ var cellStyle = new GUIStyle(GUI.skin.label)
+ {
+ fontSize = 14,
+ fontStyle = FontStyle.Bold,
+ padding = new RectOffset(5, 0, 0, 0)
+ };
+
+ for (int i = 0; i < ConfigColumnWidths.Length; i++)
+ {
+ ConfigColumnWidths[i] = 30f;
+ }
+
+ foreach (var row in rows)
+ {
+ string nameText = row.DisplayName;
+ if (row.Indent) nameText = " ↳ " + nameText;
+
+ string[] cellValues = new string[]
+ {
+ nameText,
+ GetThrustString(row.Node),
+ GetMinThrottleString(row.Node),
+ GetIspString(row.Node),
+ GetMassString(row.Node),
+ GetGimbalString(row.Node),
+ GetIgnitionsString(row.Node),
+ GetBoolSymbol(row.Node, "ullage"),
+ GetBoolSymbol(row.Node, "pressureFed"),
+ GetRatedBurnTimeString(row.Node),
+ GetTestedBurnTimeString(row.Node),
+ GetIgnitionReliabilityString(row.Node),
+ GetCycleReliabilityStartString(row.Node),
+ GetCycleReliabilityEndString(row.Node),
+ GetSurvivalAtTimeString(row.Node),
+ GetTechString(row.Node),
+ GetCostDeltaString(row.Node),
+ ""
+ };
+
+ for (int i = 0; i < cellValues.Length; i++)
+ {
+ if (!string.IsNullOrEmpty(cellValues[i]))
+ {
+ float width = cellStyle.CalcSize(new GUIContent(cellValues[i])).x + 10f;
+ if (width > ConfigColumnWidths[i])
+ ConfigColumnWidths[i] = width;
+ }
+ }
+ }
+
+ // Calculate dynamic width for Actions column (index 17) based on button labels
+ float maxActionWidth = 0f;
+ var buttonStyle = GUI.skin.button;
+ foreach (var row in rows)
+ {
+ string configName = row.Node.GetValue("name");
+ bool unlocked = EngineConfigTechLevels.UnlockedConfig(row.Node, _module.part);
+ double cost = EntryCostManager.Instance.ConfigEntryCost(configName);
+
+ // Calculate Switch button width (must match DrawActionCell padding)
+ string switchLabel = "Switch";
+ float switchWidth = buttonStyle.CalcSize(new GUIContent(switchLabel)).x + 10f; // Add padding to match DrawActionCell
+
+ // Calculate Purchase button width (can vary based on cost)
+ string purchaseLabel;
+ if (cost > 0)
+ {
+ double displayCost = cost;
+ // Check if credits would reduce the cost
+ if (!unlocked && TryGetCreditAdjustedCost(cost, out _, out double costAfterCredits))
+ displayCost = costAfterCredits;
+
+ purchaseLabel = unlocked ? "Owned" : $"Buy ({displayCost:N0}√)";
+ }
+ else
+ {
+ purchaseLabel = unlocked ? "Owned" : "Free";
+ }
+ float purchaseWidth = buttonStyle.CalcSize(new GUIContent(purchaseLabel)).x + 10f; // Add padding to match DrawActionCell
+
+ // Total width = both buttons + spacing between them (must match DrawActionCell)
+ float totalWidth = switchWidth + purchaseWidth + 4f; // 4px spacing to match DrawActionCell
+ if (totalWidth > maxActionWidth)
+ maxActionWidth = totalWidth;
+ }
+
+ ConfigColumnWidths[17] = Mathf.Max(maxActionWidth, 160f); // Minimum 160px
+ ConfigColumnWidths[7] = Mathf.Max(ConfigColumnWidths[7], 30f);
+ ConfigColumnWidths[8] = Mathf.Max(ConfigColumnWidths[8], 30f);
+ ConfigColumnWidths[9] = Mathf.Max(ConfigColumnWidths[9], 50f);
+ ConfigColumnWidths[10] = Mathf.Max(ConfigColumnWidths[10], 50f);
+
+ // Fix survival column (index 14) to maximum width to prevent window resizing during slider use
+ // Calculate width based on "100.0% / 100.0%" (the widest possible value)
+ float maxSurvivalWidth = cellStyle.CalcSize(new GUIContent("100.0% / 100.0%")).x + 10f;
+ ConfigColumnWidths[14] = maxSurvivalWidth;
+ }
+
+ #endregion
+
+ #region Cell Formatters
+
+ internal string GetThrustString(ConfigNode node)
+ {
+ if (!node.HasValue(_module.thrustRating))
+ return "-";
+
+ float thrust = _module.scale * _techLevels.ThrustTL(node.GetValue(_module.thrustRating), node);
+ if (thrust >= 100f)
+ return $"{thrust:N0} kN";
+ return $"{thrust:N2} kN";
+ }
+
+ internal string GetMinThrottleString(ConfigNode node)
+ {
+ float value = -1f;
+ if (node.HasValue("minThrust") && node.HasValue(_module.thrustRating))
+ {
+ float.TryParse(node.GetValue("minThrust"), out float minT);
+ float.TryParse(node.GetValue(_module.thrustRating), out float maxT);
+ if (maxT > 0)
+ value = minT / maxT;
+ }
+ else if (node.HasValue("throttle"))
+ {
+ float.TryParse(node.GetValue("throttle"), out value);
+ }
+
+ if (value < 0f)
+ return "-";
+ return value.ToString("P0");
+ }
+
+ internal string GetIspString(ConfigNode node)
+ {
+ if (node.HasNode("atmosphereCurve"))
+ {
+ FloatCurve isp = new FloatCurve();
+ isp.Load(node.GetNode("atmosphereCurve"));
+ float ispVac = isp.Evaluate(isp.maxTime);
+ float ispSL = isp.Evaluate(isp.minTime);
+ return $"{ispVac:N0}-{ispSL:N0}";
+ }
+
+ if (node.HasValue("IspSL") && node.HasValue("IspV"))
+ {
+ float.TryParse(node.GetValue("IspSL"), out float ispSL);
+ float.TryParse(node.GetValue("IspV"), out float ispV);
+ if (_module.techLevel != -1)
+ {
+ TechLevel cTL = new TechLevel();
+ if (cTL.Load(node, _module.techNodes, _module.engineType, _module.techLevel))
+ {
+ ispSL *= ModuleEngineConfigsBase.ispSLMult * cTL.AtmosphereCurve.Evaluate(1);
+ ispV *= ModuleEngineConfigsBase.ispVMult * cTL.AtmosphereCurve.Evaluate(0);
+ }
+ }
+ return $"{ispV:N0}-{ispSL:N0}";
+ }
+
+ return "-";
+ }
+
+ internal string GetMassString(ConfigNode node)
+ {
+ if (_module.origMass <= 0f)
+ return "-";
+
+ float cMass = _module.scale * _module.origMass * RFSettings.Instance.EngineMassMultiplier;
+ if (node.HasValue("massMult") && float.TryParse(node.GetValue("massMult"), out float ftmp))
+ cMass *= ftmp;
+
+ return $"{cMass:N3}t";
+ }
+
+ internal string GetGimbalString(ConfigNode node)
+ {
+ if (!_module.part.HasModuleImplementing())
+ return "✗";
+
+ var gimbals = _module.ExtractGimbals(node);
+
+ if (gimbals.Count == 0 && _module.techLevel != -1 && (!_module.gimbalTransform.Equals(string.Empty) || _module.useGimbalAnyway))
+ {
+ TechLevel cTL = new TechLevel();
+ if (cTL.Load(node, _module.techNodes, _module.engineType, _module.techLevel))
+ {
+ float gimbalRange = cTL.GimbalRange;
+ if (node.HasValue("gimbalMult"))
+ gimbalRange *= float.Parse(node.GetValue("gimbalMult"), CultureInfo.InvariantCulture);
+
+ if (gimbalRange >= 0)
+ return $"{gimbalRange * _module.gimbalMult:0.#}°";
+ }
+ }
+
+ if (gimbals.Count == 0)
+ {
+ foreach (var gimbalMod in _module.part.Modules.OfType())
+ {
+ if (gimbalMod != null)
+ {
+ var gimbal = new Gimbal(gimbalMod.gimbalRange, gimbalMod.gimbalRange, gimbalMod.gimbalRange, gimbalMod.gimbalRange, gimbalMod.gimbalRange);
+ gimbals[gimbalMod.gimbalTransformName] = gimbal;
+ }
+ }
+ }
+
+ if (gimbals.Count == 0)
+ return "✗";
+
+ var first = gimbals.Values.First();
+ bool allSame = gimbals.Values.All(g => g.gimbalRange == first.gimbalRange
+ && g.gimbalRangeXP == first.gimbalRangeXP
+ && g.gimbalRangeXN == first.gimbalRangeXN
+ && g.gimbalRangeYP == first.gimbalRangeYP
+ && g.gimbalRangeYN == first.gimbalRangeYN);
+
+ if (allSame)
+ return first.Info();
+
+ var uniqueInfos = gimbals.Values.Select(g => g.Info()).Distinct().OrderBy(s => s);
+ return string.Join(", ", uniqueInfos);
+ }
+
+ internal string GetIgnitionsString(ConfigNode node)
+ {
+ if (!node.HasValue("ignitions"))
+ return "-";
+
+ if (!int.TryParse(node.GetValue("ignitions"), out int ignitions))
+ return "∞";
+
+ int resolved = _techLevels.ConfigIgnitions(ignitions);
+ if (resolved == -1)
+ return "∞";
+ if (resolved == 0 && _module.literalZeroIgnitions)
+ return "Gnd";
+ return resolved.ToString();
+ }
+
+ internal string GetBoolSymbol(ConfigNode node, string key)
+ {
+ if (!node.HasValue(key))
+ return "✗";
+ bool isTrue = node.GetValue(key).ToLower() == "true";
+ return isTrue ? "✓" : "✗";
+ }
+
+ internal string GetRatedBurnTimeString(ConfigNode node)
+ {
+ bool hasRatedBurnTime = node.HasValue("ratedBurnTime");
+ bool hasRatedContinuousBurnTime = node.HasValue("ratedContinuousBurnTime");
+
+ if (!hasRatedBurnTime && !hasRatedContinuousBurnTime)
+ return "∞";
+
+ if (hasRatedBurnTime && hasRatedContinuousBurnTime)
+ {
+ string continuous = node.GetValue("ratedContinuousBurnTime");
+ string cumulative = node.GetValue("ratedBurnTime");
+ return $"{continuous}/{cumulative}";
+ }
+
+ return hasRatedBurnTime ? node.GetValue("ratedBurnTime") : node.GetValue("ratedContinuousBurnTime");
+ }
+
+ internal string GetTestedBurnTimeString(ConfigNode node)
+ {
+ if (!node.HasValue("testedBurnTime"))
+ return "-";
+
+ float testedBurnTime = 0f;
+ if (node.TryGetValue("testedBurnTime", ref testedBurnTime))
+ return testedBurnTime.ToString("F0");
+
+ return "-";
+ }
+
+ internal string GetIgnitionReliabilityString(ConfigNode node)
+ {
+ if (!node.HasValue("ignitionReliabilityStart") || !node.HasValue("ignitionReliabilityEnd"))
+ return "-";
+
+ if (!float.TryParse(node.GetValue("ignitionReliabilityStart"), out float valStart)) return "-";
+ if (!float.TryParse(node.GetValue("ignitionReliabilityEnd"), out float valEnd)) return "-";
+
+ return $"{valStart:P1} / {valEnd:P1}";
+ }
+
+ internal string GetCycleReliabilityStartString(ConfigNode node)
+ {
+ if (!node.HasValue("cycleReliabilityStart"))
+ return "-";
+ if (float.TryParse(node.GetValue("cycleReliabilityStart"), out float val))
+ return $"{val:P1}";
+ return "-";
+ }
+
+ internal string GetCycleReliabilityEndString(ConfigNode node)
+ {
+ if (!node.HasValue("cycleReliabilityEnd"))
+ return "-";
+ if (float.TryParse(node.GetValue("cycleReliabilityEnd"), out float val))
+ return $"{val:P1}";
+ return "-";
+ }
+
+ internal string GetSurvivalAtTimeString(ConfigNode node)
+ {
+ // Check if we have reliability data
+ if (!node.HasValue("cycleReliabilityStart") || !node.HasValue("cycleReliabilityEnd") || !node.HasValue("ratedBurnTime"))
+ return "-";
+
+ if (!float.TryParse(node.GetValue("cycleReliabilityStart"), out float cycleReliabilityStart)) return "-";
+ if (!float.TryParse(node.GetValue("cycleReliabilityEnd"), out float cycleReliabilityEnd)) return "-";
+ if (!float.TryParse(node.GetValue("ratedBurnTime"), out float ratedBurnTime)) return "-";
+
+ if (cycleReliabilityStart <= 0f || cycleReliabilityEnd <= 0f || ratedBurnTime <= 0f) return "-";
+
+ // Build cycle curve
+ float testedBurnTime = 0f;
+ bool hasTestedBurnTime = node.TryGetValue("testedBurnTime", ref testedBurnTime) && testedBurnTime > ratedBurnTime;
+ float overburnPenalty = 2.0f;
+ node.TryGetValue("overburnPenalty", ref overburnPenalty);
+ FloatCurve cycleCurve = ChartMath.BuildTestFlightCycleCurve(ratedBurnTime, testedBurnTime, overburnPenalty, hasTestedBurnTime);
+
+ // Calculate survival at slider time
+ float baseRateStart = -Mathf.Log(cycleReliabilityStart) / ratedBurnTime;
+ float baseRateEnd = -Mathf.Log(cycleReliabilityEnd) / ratedBurnTime;
+
+ float surviveStart = ChartMath.CalculateSurvivalProbAtTime(sliderTime, ratedBurnTime, cycleReliabilityStart, baseRateStart, cycleCurve);
+ float surviveEnd = ChartMath.CalculateSurvivalProbAtTime(sliderTime, ratedBurnTime, cycleReliabilityEnd, baseRateEnd, cycleCurve);
+
+ // Faded colors for better readability in table
+ string fadedOrange = "#FFB380";
+ string fadedGreen = "#80E680";
+
+ return $"{surviveStart:P1} / {surviveEnd:P1}";
+ }
+
+ internal string GetTechString(ConfigNode node)
+ {
+ if (!node.HasValue("techRequired"))
+ return "-";
+
+ string tech = node.GetValue("techRequired");
+ if (ModuleEngineConfigsBase.techNameToTitle.TryGetValue(tech, out string title))
+ tech = title;
+
+ var words = tech.Split(' ');
+ if (words.Length <= 1)
+ return tech;
+
+ var abbreviated = words[0];
+ for (int i = 1; i < words.Length; i++)
+ {
+ if (words[i].Length > 4)
+ abbreviated += "-" + words[i].Substring(0, 4);
+ else
+ abbreviated += "-" + words[i];
+ }
+ return abbreviated;
+ }
+
+ internal string GetCostDeltaString(ConfigNode node)
+ {
+ if (!node.HasValue("cost"))
+ return "-";
+
+ float curCost = _module.scale * float.Parse(node.GetValue("cost"), CultureInfo.InvariantCulture);
+ if (_module.techLevel != -1)
+ curCost = _techLevels.CostTL(curCost, node) - _techLevels.CostTL(0f, node);
+
+ if (Mathf.Approximately(curCost, 0f))
+ return "-";
+
+ string sign = curCost < 0 ? string.Empty : "+";
+ return $"{sign}{curCost:N0}√";
+ }
+
+ #endregion
+
+ #region Tooltips
+
+ private string GetRowTooltip(ConfigNode node)
+ {
+ List tooltipParts = new List();
+
+ // Color palette
+ string headerColor = "#FFA726"; // Orange
+ string propNameColor = "#7DD9FF"; // Cyan/Blue
+ string valueColor = "#E6D68A"; // Yellow/Gold
+ string unitColor = "#B0B0B0"; // Light gray
+
+ if (node.HasValue("description"))
+ tooltipParts.Add(node.GetValue("description"));
+
+ if (node.HasNode("PROPELLANT"))
+ {
+ float thrust = 0f;
+ float isp = 0f;
+
+ if (node.HasValue(_module.thrustRating) && float.TryParse(node.GetValue(_module.thrustRating), out float maxThrust))
+ thrust = _techLevels.ThrustTL(node.GetValue(_module.thrustRating), node) * _module.scale;
+
+ if (node.HasNode("atmosphereCurve"))
+ {
+ var atmCurve = new FloatCurve();
+ atmCurve.Load(node.GetNode("atmosphereCurve"));
+ isp = atmCurve.Evaluate(0f);
+ }
+
+ const float g0 = 9.80665f;
+ float thrustN = thrust * 1000f;
+ float totalMassFlow = (thrustN > 0f && isp > 0f) ? thrustN / (isp * g0) : 0f;
+
+ var propNodes = node.GetNodes("PROPELLANT");
+ float totalRatio = 0f;
+ foreach (var propNode in propNodes)
+ {
+ string ratioStr = null;
+ if (propNode.TryGetValue("ratio", ref ratioStr) && float.TryParse(ratioStr, out float ratio))
+ totalRatio += ratio;
+ }
+
+ var propellantLines = new List();
+ foreach (var propNode in propNodes)
+ {
+ string name = propNode.GetValue("name");
+ if (string.IsNullOrWhiteSpace(name)) continue;
+
+ string line = $" • {name}";
+
+ string ratioStr2 = null;
+ if (propNode.TryGetValue("ratio", ref ratioStr2) && float.TryParse(ratioStr2, out float ratio) && totalMassFlow > 0f && totalRatio > 0f)
+ {
+ float propMassFlow = totalMassFlow * (ratio / totalRatio);
+
+ var resource = PartResourceLibrary.Instance?.GetDefinition(name);
+
+ if (resource != null)
+ {
+ // resource.density is in t/unit, convert to kg/unit, then to units/s
+ float volumeFlow = propMassFlow / (float)(resource.density * 1000f);
+ line += $": {volumeFlow:F2} units/s";
+
+ string massFlowStr = propMassFlow >= 1f
+ ? $"{propMassFlow:F2} kg/s"
+ : $"{propMassFlow * 1000f:F1} g/s";
+ line += $" ({massFlowStr})";
+ }
+ else
+ {
+ string massFlowStr = propMassFlow >= 1f
+ ? $"{propMassFlow:F2} kg/s"
+ : $"{propMassFlow * 1000f:F1} g/s";
+ line += $": {massFlowStr}";
+ }
+ }
+
+ propellantLines.Add(line);
+ }
+
+ if (propellantLines.Count > 0)
+ tooltipParts.Add($"Propellant Consumption:\n{string.Join("\n", propellantLines)}");
+ }
+
+ return tooltipParts.Count > 0 ? string.Join("\n\n", tooltipParts) : string.Empty;
+ }
+
+ #endregion
+
+ #region RP-1 Credit Integration
+
+ private static void CheckRP1Integration()
+ {
+ if (_rp1Checked) return;
+ _rp1Checked = true;
+
+ try
+ {
+ // Try to find RP-1's UnlockCreditHandler
+ // Note: The assembly name is "RP-0" (with hyphen), not "RP0"
+ var rp1Assembly = AssemblyLoader.loadedAssemblies
+ .FirstOrDefault(a => a.name == "RP-0");
+
+ if (rp1Assembly != null)
+ {
+ _unlockCreditHandlerType = rp1Assembly.assembly.GetType("RP0.UnlockCreditHandler");
+ if (_unlockCreditHandlerType != null)
+ {
+ _unlockCreditInstanceProperty = _unlockCreditHandlerType.GetProperty("Instance",
+ System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static);
+ _totalCreditProperty = _unlockCreditHandlerType.GetProperty("TotalCredit",
+ System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance);
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ Debug.LogError($"[RealFuels] Failed to initialize RP-1 integration: {ex.Message}");
+ }
+ }
+
+ private static bool TryGetCreditAdjustedCost(double entryCost, out double creditsAvailable, out double costAfterCredits)
+ {
+ creditsAvailable = 0;
+ costAfterCredits = entryCost;
+
+ CheckRP1Integration();
+
+ if (_unlockCreditHandlerType == null || _unlockCreditInstanceProperty == null || _totalCreditProperty == null)
+ return false;
+
+ try
+ {
+ // Cache credits per frame to avoid expensive reflection calls every button render
+ int currentFrame = Time.frameCount;
+ if (_creditCacheFrame != currentFrame)
+ {
+ var instance = _unlockCreditInstanceProperty.GetValue(null);
+ if (instance != null)
+ {
+ _cachedCredits = (double)_totalCreditProperty.GetValue(instance);
+ _creditCacheFrame = currentFrame;
+ }
+ else
+ {
+ return false;
+ }
+ }
+
+ creditsAvailable = _cachedCredits;
+ double creditsUsed = Math.Min(creditsAvailable, entryCost);
+ costAfterCredits = entryCost - creditsUsed;
+ return creditsUsed > 0;
+ }
+ catch (Exception ex)
+ {
+ Debug.LogError($"[RealFuels] Failed to query RP-1 credits: {ex.Message}");
+ }
+
+ return false;
+ }
+
+ #endregion
+
+ #region Helper Methods
+
+ internal void MarkWindowDirty()
+ {
+ lastPartId = 0;
+ }
+
+ private void EditorLock()
+ {
+ if (!editorLocked)
+ {
+ EditorLogic.fetch.Lock(false, false, false, "RFGUILock");
+ editorLocked = true;
+ KSP.UI.Screens.Editor.PartListTooltipMasterController.Instance?.HideTooltip();
+ }
+ }
+
+ private void EditorUnlock()
+ {
+ if (editorLocked)
+ {
+ EditorLogic.fetch.Unlock("RFGUILock");
+ editorLocked = false;
+ }
+ }
+
+ private void EnsureTexturesAndStyles()
+ {
+ _textures.EnsureInitialized();
+ EngineConfigStyles.Initialize();
+ }
+
+ #endregion
+ }
+}
diff --git a/Source/Engines/UI/EngineConfigInfoPanel.cs b/Source/Engines/UI/EngineConfigInfoPanel.cs
new file mode 100644
index 00000000..ca93afd4
--- /dev/null
+++ b/Source/Engines/UI/EngineConfigInfoPanel.cs
@@ -0,0 +1,410 @@
+using System;
+using UnityEngine;
+
+namespace RealFuels
+{
+ ///
+ /// Handles the info panel display showing reliability stats, data gains, and simulation controls.
+ ///
+ public class EngineConfigInfoPanel
+ {
+ private readonly ModuleEngineConfigsBase _module;
+ private readonly EngineConfigTextures _textures;
+
+ public EngineConfigInfoPanel(ModuleEngineConfigsBase module)
+ {
+ _module = module;
+ _textures = EngineConfigTextures.Instance;
+ }
+
+ public void Draw(Rect rect, float ratedBurnTime, float testedBurnTime, bool hasTestedBurnTime,
+ float cycleReliabilityStart, float cycleReliabilityEnd, float ignitionReliabilityStart, float ignitionReliabilityEnd,
+ bool hasCurrentData, float cycleReliabilityCurrent, float ignitionReliabilityCurrent, float dataPercentage,
+ float currentDataValue, float maxDataValue, float realCurrentData, float realMaxData,
+ ref bool useSimulatedData, ref float simulatedDataValue, ref int clusterSize,
+ ref string clusterSizeInput, ref string dataValueInput, ref float sliderTime, ref string sliderTimeInput,
+ ref bool includeIgnition, FloatCurve cycleCurve, float maxGraphTime)
+ {
+ // Draw background
+ if (Event.current.type == EventType.Repaint)
+ {
+ GUI.DrawTexture(rect, _textures.InfoPanelBg);
+ }
+
+ float yPos = rect.y + 4;
+
+ // Draw reliability section (burn survival - three sections with optional ignition text)
+ yPos = DrawReliabilitySection(rect, yPos, ratedBurnTime,
+ cycleReliabilityStart, cycleReliabilityEnd, cycleReliabilityCurrent,
+ ignitionReliabilityStart, ignitionReliabilityEnd, ignitionReliabilityCurrent,
+ hasCurrentData, cycleCurve, clusterSize, sliderTime, includeIgnition);
+
+ // Separator
+ if (Event.current.type == EventType.Repaint)
+ {
+ GUI.DrawTexture(new Rect(rect.x + 8, yPos, rect.width - 16, 1), _textures.ChartSeparator);
+ }
+ yPos += 10;
+
+ // Side-by-side: Data Gains (left) and Controls (right)
+ yPos = DrawSideBySideSection(rect, yPos, ratedBurnTime, maxGraphTime, maxDataValue, realCurrentData, realMaxData,
+ ref useSimulatedData, ref simulatedDataValue, ref clusterSize, ref clusterSizeInput, ref dataValueInput,
+ ref sliderTime, ref sliderTimeInput, ref includeIgnition);
+ }
+
+ #region Reliability Section
+
+ private float DrawReliabilitySection(Rect rect, float yPos,
+ float ratedBurnTime,
+ float cycleReliabilityStart, float cycleReliabilityEnd, float cycleReliabilityCurrent,
+ float ignitionReliabilityStart, float ignitionReliabilityEnd, float ignitionReliabilityCurrent,
+ bool hasCurrentData, FloatCurve cycleCurve,
+ int clusterSize, float sliderTime, bool includeIgnition)
+ {
+ // Color codes
+ string orangeColor = "#FF8033";
+ string blueColor = "#7DD9FF";
+ string greenColor = "#4DE64D";
+
+ // Calculate base rates
+ float baseRateStart = -Mathf.Log(cycleReliabilityStart) / ratedBurnTime;
+ float baseRateEnd = -Mathf.Log(cycleReliabilityEnd) / ratedBurnTime;
+ float baseRateCurrent = hasCurrentData ? -Mathf.Log(cycleReliabilityCurrent) / ratedBurnTime : 0f;
+
+ // Calculate burn survival probabilities at slider time
+ float surviveStart = ChartMath.CalculateSurvivalProbAtTime(sliderTime, ratedBurnTime, cycleReliabilityStart, baseRateStart, cycleCurve);
+ float surviveEnd = ChartMath.CalculateSurvivalProbAtTime(sliderTime, ratedBurnTime, cycleReliabilityEnd, baseRateEnd, cycleCurve);
+ float surviveCurrent = hasCurrentData ? ChartMath.CalculateSurvivalProbAtTime(sliderTime, ratedBurnTime, cycleReliabilityCurrent, baseRateCurrent, cycleCurve) : 0f;
+
+ // If including ignition, multiply by ignition reliability
+ if (includeIgnition)
+ {
+ surviveStart *= ignitionReliabilityStart;
+ surviveEnd *= ignitionReliabilityEnd;
+ if (hasCurrentData) surviveCurrent *= ignitionReliabilityCurrent;
+ }
+
+ // Apply cluster math
+ if (clusterSize > 1)
+ {
+ surviveStart = Mathf.Pow(surviveStart, clusterSize);
+ surviveEnd = Mathf.Pow(surviveEnd, clusterSize);
+ if (hasCurrentData) surviveCurrent = Mathf.Pow(surviveCurrent, clusterSize);
+ }
+
+ // Layout: three sections side-by-side
+ float sectionHeight = 125f; // Keep constant height to prevent window jumping
+ float totalWidth = rect.width - 16f;
+ float numSections = hasCurrentData ? 3f : 2f;
+ float sectionWidth = totalWidth / numSections;
+ float startX = rect.x + 8f;
+
+ float currentX = startX;
+
+ // Calculate ignition probabilities with cluster math
+ float igniteStart = clusterSize > 1 ? Mathf.Pow(ignitionReliabilityStart, clusterSize) : ignitionReliabilityStart;
+ float igniteEnd = clusterSize > 1 ? Mathf.Pow(ignitionReliabilityEnd, clusterSize) : ignitionReliabilityEnd;
+ float igniteCurrent = hasCurrentData ? (clusterSize > 1 ? Mathf.Pow(ignitionReliabilityCurrent, clusterSize) : ignitionReliabilityCurrent) : 0f;
+
+ // Draw Starting DU section
+ DrawSurvivalSection(currentX, yPos, sectionWidth, sectionHeight, "Starting DU", orangeColor, surviveStart, sliderTime, clusterSize, igniteStart, includeIgnition);
+ currentX += sectionWidth;
+
+ // Draw Current DU section (if applicable)
+ if (hasCurrentData)
+ {
+ DrawSurvivalSection(currentX, yPos, sectionWidth, sectionHeight, "Current DU", blueColor, surviveCurrent, sliderTime, clusterSize, igniteCurrent, includeIgnition);
+ currentX += sectionWidth;
+ }
+
+ // Draw Max DU section
+ DrawSurvivalSection(currentX, yPos, sectionWidth, sectionHeight, "Max DU", greenColor, surviveEnd, sliderTime, clusterSize, igniteEnd, includeIgnition);
+
+ yPos += sectionHeight + 12;
+
+ return yPos;
+ }
+
+ private void DrawSurvivalSection(float x, float y, float width, float height, string title, string color, float survivalProb, float time, int clusterSize, float ignitionProb, bool includeIgnition)
+ {
+ // Header with colored text (no background)
+ GUIStyle headerStyle = new GUIStyle(GUI.skin.label)
+ {
+ fontSize = 18,
+ fontStyle = FontStyle.Bold,
+ alignment = TextAnchor.UpperCenter,
+ richText = true,
+ normal = { textColor = Color.white }
+ };
+
+ string headerText = $"{title}";
+ GUI.Label(new Rect(x, y, width, 24), headerText, headerStyle);
+
+ // Survival probability
+ float survivalPercent = survivalProb * 100f;
+ string survivalText = $"{survivalPercent:F2}%";
+ GUIStyle survivalStyle = new GUIStyle(GUI.skin.label)
+ {
+ fontSize = 20,
+ alignment = TextAnchor.MiddleCenter,
+ richText = true,
+ normal = { textColor = Color.white }
+ };
+ GUI.Label(new Rect(x, y + 26, width, 28), survivalText, survivalStyle);
+
+ // "1 in X" text
+ float failureRate = 1f - survivalProb;
+ float oneInX = failureRate > 0.0001f ? (1f / failureRate) : 9999f;
+ string entityText = clusterSize > 1 ? $"cluster of {clusterSize}" : "burn";
+ string failText = $"1 in {oneInX:F1} {entityText}s will fail to reach {ChartMath.FormatTime(time)}";
+ GUIStyle failStyle = new GUIStyle(GUI.skin.label)
+ {
+ fontSize = 13,
+ alignment = TextAnchor.UpperCenter,
+ richText = true,
+ wordWrap = true,
+ normal = { textColor = new Color(0.9f, 0.9f, 0.9f) }
+ };
+ GUI.Label(new Rect(x + 4, y + 54, width - 8, 50), failText, failStyle);
+
+ // Small ignition probability text (only when not including ignition)
+ if (!includeIgnition)
+ {
+ float ignitionPercent = ignitionProb * 100f;
+ string ignitionText = $"Ignition: {ignitionPercent:F2}%";
+ GUIStyle ignitionStyle = new GUIStyle(GUI.skin.label)
+ {
+ fontSize = 13,
+ alignment = TextAnchor.UpperCenter,
+ normal = { textColor = new Color(0.9f, 0.9f, 0.9f) }
+ };
+ GUI.Label(new Rect(x + 4, y + 92, width - 8, 18), ignitionText, ignitionStyle);
+ }
+ }
+
+ #endregion
+
+ #region Side-by-Side Section
+
+ private float DrawSideBySideSection(Rect rect, float yPos, float ratedBurnTime, float maxGraphTime, float maxDataValue,
+ float realCurrentData, float realMaxData,
+ ref bool useSimulatedData, ref float simulatedDataValue, ref int clusterSize,
+ ref string clusterSizeInput, ref string dataValueInput, ref float sliderTime, ref string sliderTimeInput, ref bool includeIgnition)
+ {
+ float columnStartY = yPos;
+ float leftColumnWidth = rect.width * 0.5f;
+ float rightColumnWidth = rect.width * 0.5f;
+ float leftColumnX = rect.x;
+ float rightColumnX = rect.x + leftColumnWidth;
+
+ // Draw left column: Data Gains
+ float leftColumnEndY = DrawDataGainsSection(leftColumnX, leftColumnWidth, columnStartY, ratedBurnTime);
+
+ // Draw right column: Simulation Controls
+ float rightColumnEndY = DrawSimulationControls(rightColumnX, rightColumnWidth, columnStartY,
+ maxGraphTime, maxDataValue, realCurrentData, realMaxData,
+ ref useSimulatedData, ref simulatedDataValue, ref clusterSize, ref clusterSizeInput, ref dataValueInput,
+ ref sliderTime, ref sliderTimeInput, ref includeIgnition);
+
+ // Draw vertical separator
+ if (Event.current.type == EventType.Repaint)
+ {
+ float separatorX = rect.x + leftColumnWidth;
+ float separatorHeight = Mathf.Max(leftColumnEndY, rightColumnEndY) - columnStartY;
+ GUI.DrawTexture(new Rect(separatorX, columnStartY, 1, separatorHeight), _textures.ChartSeparator);
+ }
+
+ return Mathf.Max(leftColumnEndY, rightColumnEndY) + 8;
+ }
+
+ private float DrawDataGainsSection(float x, float width, float yPos, float ratedBurnTime)
+ {
+ string purpleColor = "#CCB3FF";
+
+ float ratedContinuousBurnTime = ratedBurnTime;
+ float dataRate = 640f / ratedContinuousBurnTime;
+
+ // Section header
+ GUIStyle sectionStyle = EngineConfigStyles.InfoSection;
+ sectionStyle.normal.textColor = new Color(0.8f, 0.7f, 1.0f);
+ GUI.Label(new Rect(x, yPos, width, 20), "How To Gain Data:", sectionStyle);
+ yPos += 24;
+
+ GUIStyle bulletStyle = EngineConfigStyles.Bullet;
+ GUIStyle indentedBulletStyle = EngineConfigStyles.IndentedBullet;
+ GUIStyle footerStyle = EngineConfigStyles.Footer;
+ float bulletHeight = 18;
+
+ // Failures section
+ GUI.Label(new Rect(x, yPos, width, bulletHeight), "An engine can fail in 4 ways:", bulletStyle);
+ yPos += bulletHeight;
+
+ string[] failureTypes = { "Shutdown", "Perf. Loss", "Reduced Thrust", "Explode" };
+ int[] failureDu = { 1000, 800, 700, 1000 };
+ float[] failurePercents = { 55.2f, 27.6f, 13.8f, 3.4f };
+
+ for (int i = 0; i < failureTypes.Length; i++)
+ {
+ string failText = $" ({failurePercents[i]:F0}%) {failureTypes[i]} +{failureDu[i]} du";
+ GUI.Label(new Rect(x, yPos, width, bulletHeight), failText, indentedBulletStyle);
+ yPos += bulletHeight;
+ }
+
+ yPos += 4;
+
+ // Running gains
+ string runningText = $"Running gains {dataRate:F1} du/s";
+ GUI.Label(new Rect(x, yPos, width, bulletHeight), runningText, bulletStyle);
+ yPos += bulletHeight;
+
+ // Ignition failure
+ string ignitionText = $"Ignition Fail +1000 du";
+ GUI.Label(new Rect(x, yPos, width, bulletHeight), ignitionText, bulletStyle);
+ yPos += bulletHeight + 8;
+
+ // Footer
+ string footerText = "(no more than 1000 du per flight)";
+ GUI.Label(new Rect(x, yPos, width, bulletHeight), footerText, footerStyle);
+ yPos += bulletHeight;
+
+ return yPos;
+ }
+
+ private float DrawSimulationControls(float x, float width, float yPos, float maxGraphTime, float maxDataValue,
+ float realCurrentData, float realMaxData,
+ ref bool useSimulatedData, ref float simulatedDataValue, ref int clusterSize,
+ ref string clusterSizeInput, ref string dataValueInput, ref float sliderTime, ref string sliderTimeInput, ref bool includeIgnition)
+ {
+ bool hasRealData = realCurrentData >= 0f && realMaxData > 0f;
+
+ GUIStyle sectionStyle = EngineConfigStyles.InfoSection;
+ sectionStyle.normal.textColor = Color.white;
+ GUI.Label(new Rect(x, yPos, width, 20), "Simulate:", sectionStyle);
+ yPos += 24;
+
+ GUIStyle buttonStyle = EngineConfigStyles.CompactButton;
+ var inputStyle = new GUIStyle(GUI.skin.textField) { fontSize = 12, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter };
+ GUIStyle controlStyle = EngineConfigStyles.Control;
+
+ // Common width for all controls
+ float btnWidth = width - 16;
+
+ // Burn Time slider (first control) - max matches graph range
+ GUI.Label(new Rect(x + 8, yPos, btnWidth, 16), "Burn Time (s)", controlStyle);
+ yPos += 16;
+
+ float maxSliderTime = maxGraphTime;
+ sliderTime = GUI.HorizontalSlider(new Rect(x + 8, yPos, btnWidth - 50, 16),
+ sliderTime, 0f, maxSliderTime, GUI.skin.horizontalSlider, GUI.skin.horizontalSliderThumb);
+
+ sliderTimeInput = $"{sliderTime:F1}";
+ GUI.SetNextControlName("sliderTimeInput");
+ string newTimeInput = GUI.TextField(new Rect(x + btnWidth - 35, yPos - 2, 40, 20),
+ sliderTimeInput, 8, inputStyle);
+
+ if (newTimeInput != sliderTimeInput)
+ {
+ sliderTimeInput = newTimeInput;
+ if (GUI.GetNameOfFocusedControl() == "sliderTimeInput" && float.TryParse(sliderTimeInput, out float inputTime))
+ {
+ inputTime = Mathf.Clamp(inputTime, 0f, maxSliderTime);
+ sliderTime = inputTime;
+ }
+ }
+ yPos += 24;
+
+ // Include Ignition checkbox
+ GUIStyle checkboxStyle = new GUIStyle(GUI.skin.toggle)
+ {
+ fontSize = 12,
+ normal = { textColor = Color.white }
+ };
+ includeIgnition = GUI.Toggle(new Rect(x + 8, yPos, btnWidth, 20), includeIgnition, " Include Ignition", checkboxStyle);
+ yPos += 24;
+
+ // Reset button
+ string resetButtonText = hasRealData ? $"Set to Current du ({realCurrentData:F0})" : "Set to Current du (0)";
+ if (GUI.Button(new Rect(x + 8, yPos, btnWidth, 20), resetButtonText, buttonStyle))
+ {
+ if (hasRealData)
+ {
+ simulatedDataValue = realCurrentData;
+ dataValueInput = $"{realCurrentData:F0}";
+ useSimulatedData = false;
+ }
+ else
+ {
+ simulatedDataValue = 0f;
+ dataValueInput = "0";
+ useSimulatedData = true;
+ }
+ clusterSize = 1;
+ clusterSizeInput = "1";
+ }
+ yPos += 24;
+
+ // Data slider
+ GUI.Label(new Rect(x + 8, yPos, btnWidth, 16), "Data (du)", controlStyle);
+ yPos += 16;
+
+ if (!useSimulatedData)
+ {
+ simulatedDataValue = hasRealData ? realCurrentData : 0f;
+ dataValueInput = $"{simulatedDataValue:F0}";
+ }
+
+ simulatedDataValue = GUI.HorizontalSlider(new Rect(x + 8, yPos, btnWidth - 50, 16),
+ simulatedDataValue, 0f, maxDataValue, GUI.skin.horizontalSlider, GUI.skin.horizontalSliderThumb);
+
+ if (hasRealData && Mathf.Abs(simulatedDataValue - realCurrentData) > 0.1f)
+ useSimulatedData = true;
+ else if (!hasRealData && simulatedDataValue > 0.1f)
+ useSimulatedData = true;
+
+ dataValueInput = $"{simulatedDataValue:F0}";
+ GUI.SetNextControlName("dataValueInput");
+ string newDataInput = GUI.TextField(new Rect(x + btnWidth - 35, yPos - 2, 40, 20),
+ dataValueInput, 6, inputStyle);
+
+ if (newDataInput != dataValueInput)
+ {
+ dataValueInput = newDataInput;
+ if (GUI.GetNameOfFocusedControl() == "dataValueInput" && float.TryParse(dataValueInput, out float inputDataValue))
+ {
+ inputDataValue = Mathf.Clamp(inputDataValue, 0f, maxDataValue);
+ simulatedDataValue = inputDataValue;
+ useSimulatedData = true;
+ }
+ }
+ yPos += 24;
+
+ // Cluster slider
+ GUI.Label(new Rect(x + 8, yPos, btnWidth, 16), "Cluster", controlStyle);
+ yPos += 16;
+
+ clusterSize = Mathf.RoundToInt(GUI.HorizontalSlider(new Rect(x + 8, yPos, btnWidth - 50, 16),
+ clusterSize, 1f, 100f, GUI.skin.horizontalSlider, GUI.skin.horizontalSliderThumb));
+
+ clusterSizeInput = clusterSize.ToString();
+ GUI.SetNextControlName("clusterSizeInput");
+ string newClusterInput = GUI.TextField(new Rect(x + btnWidth - 35, yPos - 2, 40, 20),
+ clusterSizeInput, 3, inputStyle);
+
+ if (newClusterInput != clusterSizeInput)
+ {
+ clusterSizeInput = newClusterInput;
+ if (GUI.GetNameOfFocusedControl() == "clusterSizeInput" && int.TryParse(clusterSizeInput, out int inputCluster))
+ {
+ inputCluster = Mathf.Clamp(inputCluster, 1, 100);
+ clusterSize = inputCluster;
+ clusterSizeInput = clusterSize.ToString();
+ }
+ }
+ yPos += 24;
+
+ return yPos;
+ }
+
+ #endregion
+ }
+}
diff --git a/Source/Engines/UI/EngineConfigStyles.cs b/Source/Engines/UI/EngineConfigStyles.cs
new file mode 100644
index 00000000..9e43c643
--- /dev/null
+++ b/Source/Engines/UI/EngineConfigStyles.cs
@@ -0,0 +1,245 @@
+using System;
+using UnityEngine;
+
+namespace RealFuels
+{
+ ///
+ /// Cached GUIStyle objects to prevent allocation every frame.
+ /// Styles are initialized once and reused across all GUI rendering.
+ ///
+ public static class EngineConfigStyles
+ {
+ private static bool _initialized = false;
+
+ // Header styles
+ public static GUIStyle HeaderCell { get; private set; }
+ public static GUIStyle HeaderCellHover { get; private set; }
+
+ // Row styles
+ public static GUIStyle RowPrimary { get; private set; }
+ public static GUIStyle RowPrimaryHover { get; private set; }
+ public static GUIStyle RowPrimaryLocked { get; private set; }
+ public static GUIStyle RowSecondary { get; private set; }
+
+ // Button styles
+ public static GUIStyle SmallButton { get; private set; }
+ public static GUIStyle CompactButton { get; private set; }
+
+ // Label styles
+ public static GUIStyle TimeLabel { get; private set; }
+ public static GUIStyle GridLabel { get; private set; }
+ public static GUIStyle ChartTitle { get; private set; }
+ public static GUIStyle Legend { get; private set; }
+
+ // Info panel styles
+ public static GUIStyle InfoText { get; private set; }
+ public static GUIStyle InfoHeader { get; private set; }
+ public static GUIStyle InfoSection { get; private set; }
+ public static GUIStyle Bullet { get; private set; }
+ public static GUIStyle IndentedBullet { get; private set; }
+ public static GUIStyle Footer { get; private set; }
+ public static GUIStyle Control { get; private set; }
+ public static GUIStyle FailureRate { get; private set; }
+
+ // Tooltip style
+ public static GUIStyle ChartTooltip { get; private set; }
+
+ // Menu styles
+ public static GUIStyle MenuHeader { get; private set; }
+ public static GUIStyle MenuLabel { get; private set; }
+
+ ///
+ /// Initialize all cached styles. Called once on first use.
+ ///
+ public static void Initialize()
+ {
+ if (_initialized) return;
+
+ // Header styles
+ HeaderCell = new GUIStyle(GUI.skin.label)
+ {
+ fontSize = 14,
+ fontStyle = FontStyle.Bold,
+ normal = { textColor = new Color(0.9f, 0.9f, 0.9f) },
+ alignment = TextAnchor.LowerLeft,
+ richText = true
+ };
+
+ HeaderCellHover = new GUIStyle(HeaderCell)
+ {
+ fontSize = 15
+ };
+
+ // Row styles
+ RowPrimary = new GUIStyle(GUI.skin.label)
+ {
+ fontSize = 14,
+ fontStyle = FontStyle.Bold,
+ normal = { textColor = new Color(0.85f, 0.85f, 0.85f) },
+ alignment = TextAnchor.MiddleLeft,
+ richText = true,
+ padding = new RectOffset(5, 0, 0, 0)
+ };
+
+ RowPrimaryHover = new GUIStyle(RowPrimary)
+ {
+ fontSize = 15
+ };
+
+ RowPrimaryLocked = new GUIStyle(RowPrimary)
+ {
+ normal = { textColor = new Color(1f, 0.65f, 0.3f) }
+ };
+
+ RowSecondary = new GUIStyle(RowPrimary)
+ {
+ normal = { textColor = new Color(0.7f, 0.7f, 0.7f) }
+ };
+
+ // Button styles
+ SmallButton = new GUIStyle(HighLogic.Skin.button)
+ {
+ fontSize = 11,
+ padding = new RectOffset(2, 2, 2, 2)
+ };
+
+ CompactButton = new GUIStyle(GUI.skin.button)
+ {
+ fontSize = 12,
+ fontStyle = FontStyle.Bold
+ };
+
+ // Label styles
+ TimeLabel = new GUIStyle(GUI.skin.label)
+ {
+ fontSize = 13,
+ normal = { textColor = Color.grey },
+ alignment = TextAnchor.UpperCenter
+ };
+
+ GridLabel = new GUIStyle(GUI.skin.label)
+ {
+ fontSize = 13,
+ normal = { textColor = Color.grey }
+ };
+
+ ChartTitle = new GUIStyle(GUI.skin.label)
+ {
+ fontSize = 16,
+ fontStyle = FontStyle.Bold,
+ normal = { textColor = Color.white },
+ alignment = TextAnchor.MiddleCenter
+ };
+
+ Legend = new GUIStyle(GUI.skin.label)
+ {
+ fontSize = 13,
+ normal = { textColor = Color.white },
+ alignment = TextAnchor.UpperLeft
+ };
+
+ // Info panel styles
+ InfoText = new GUIStyle(GUI.skin.label)
+ {
+ fontSize = 15,
+ normal = { textColor = Color.white },
+ wordWrap = true,
+ richText = true,
+ padding = new RectOffset(8, 8, 2, 2)
+ };
+
+ InfoHeader = new GUIStyle(GUI.skin.label)
+ {
+ fontSize = 17,
+ fontStyle = FontStyle.Bold,
+ normal = { textColor = new Color(0.9f, 0.9f, 0.9f) },
+ wordWrap = true,
+ richText = true,
+ padding = new RectOffset(8, 8, 0, 4)
+ };
+
+ InfoSection = new GUIStyle(GUI.skin.label)
+ {
+ fontSize = 16,
+ fontStyle = FontStyle.Bold,
+ normal = { textColor = Color.white },
+ wordWrap = true,
+ richText = true,
+ padding = new RectOffset(8, 8, 4, 2)
+ };
+
+ Bullet = new GUIStyle(GUI.skin.label)
+ {
+ fontSize = 14,
+ normal = { textColor = Color.white },
+ wordWrap = false,
+ richText = true,
+ padding = new RectOffset(8, 8, 1, 1)
+ };
+
+ IndentedBullet = new GUIStyle(Bullet)
+ {
+ padding = new RectOffset(24, 8, 1, 1)
+ };
+
+ Footer = new GUIStyle(GUI.skin.label)
+ {
+ fontSize = 11,
+ normal = { textColor = new Color(0.6f, 0.6f, 0.6f) },
+ padding = new RectOffset(8, 8, 1, 1)
+ };
+
+ Control = new GUIStyle(GUI.skin.label)
+ {
+ fontSize = 12,
+ fontStyle = FontStyle.Bold,
+ normal = { textColor = new Color(0.8f, 0.8f, 0.8f) },
+ padding = new RectOffset(8, 8, 2, 2)
+ };
+
+ FailureRate = new GUIStyle(GUI.skin.label)
+ {
+ fontSize = 18,
+ fontStyle = FontStyle.Bold,
+ normal = { textColor = Color.white },
+ alignment = TextAnchor.MiddleCenter,
+ richText = true
+ };
+
+ // Tooltip style
+ ChartTooltip = new GUIStyle(GUI.skin.box)
+ {
+ fontSize = 15,
+ normal = { textColor = Color.white },
+ padding = new RectOffset(8, 8, 6, 6),
+ alignment = TextAnchor.MiddleLeft,
+ wordWrap = false,
+ richText = true
+ };
+
+ // Menu styles
+ MenuHeader = new GUIStyle(GUI.skin.label)
+ {
+ fontSize = 13,
+ fontStyle = FontStyle.Bold,
+ normal = { textColor = Color.white }
+ };
+
+ MenuLabel = new GUIStyle(GUI.skin.label)
+ {
+ fontSize = 12,
+ normal = { textColor = Color.white }
+ };
+
+ _initialized = true;
+ }
+
+ ///
+ /// Reset all styles. Call this if you need to reinitialize (e.g., after skin change).
+ ///
+ public static void Reset()
+ {
+ _initialized = false;
+ }
+ }
+}
diff --git a/Source/Engines/UI/EngineConfigTextures.cs b/Source/Engines/UI/EngineConfigTextures.cs
new file mode 100644
index 00000000..687a5c5f
--- /dev/null
+++ b/Source/Engines/UI/EngineConfigTextures.cs
@@ -0,0 +1,185 @@
+using System;
+using UnityEngine;
+
+namespace RealFuels
+{
+ ///
+ /// Manages texture lifecycle for engine config GUI.
+ /// Ensures textures are created once and properly destroyed to prevent memory leaks.
+ ///
+ public class EngineConfigTextures : IDisposable
+ {
+ private static EngineConfigTextures _instance;
+ public static EngineConfigTextures Instance
+ {
+ get
+ {
+ if (_instance == null)
+ _instance = new EngineConfigTextures();
+ return _instance;
+ }
+ }
+
+ // Table textures
+ public Texture2D RowHover { get; private set; }
+ public Texture2D RowCurrent { get; private set; }
+ public Texture2D RowLocked { get; private set; }
+ public Texture2D ZebraStripe { get; private set; }
+ public Texture2D ColumnSeparator { get; private set; }
+
+ // Chart textures
+ public Texture2D ChartBg { get; private set; }
+ public Texture2D ChartGridMajor { get; private set; }
+ public Texture2D ChartGridMinor { get; private set; }
+ public Texture2D ChartGreenZone { get; private set; }
+ public Texture2D ChartYellowZone { get; private set; }
+ public Texture2D ChartRedZone { get; private set; }
+ public Texture2D ChartDarkRedZone { get; private set; }
+ public Texture2D ChartStartupZone { get; private set; }
+ public Texture2D ChartLine { get; private set; }
+ public Texture2D ChartMarkerBlue { get; private set; }
+ public Texture2D ChartMarkerGreen { get; private set; }
+ public Texture2D ChartMarkerYellow { get; private set; }
+ public Texture2D ChartMarkerOrange { get; private set; }
+ public Texture2D ChartMarkerDarkRed { get; private set; }
+ public Texture2D ChartSeparator { get; private set; }
+ public Texture2D ChartHoverLine { get; private set; }
+ public Texture2D ChartOrangeLine { get; private set; }
+ public Texture2D ChartGreenLine { get; private set; }
+ public Texture2D ChartBlueLine { get; private set; }
+ public Texture2D ChartTooltipBg { get; private set; }
+ public Texture2D InfoPanelBg { get; private set; }
+
+ private bool _initialized = false;
+ private bool _disposed = false;
+
+ private EngineConfigTextures()
+ {
+ Initialize();
+ }
+
+ private void Initialize()
+ {
+ if (_initialized) return;
+
+ // Table textures
+ RowHover = CreateColorPixel(new Color(1f, 1f, 1f, 0.05f));
+ RowCurrent = CreateColorPixel(new Color(0.3f, 0.6f, 1.0f, 0.20f));
+ RowLocked = CreateColorPixel(new Color(1f, 0.5f, 0.3f, 0.15f));
+ ZebraStripe = CreateColorPixel(new Color(0.05f, 0.05f, 0.05f, 0.3f));
+ ColumnSeparator = CreateColorPixel(new Color(0.25f, 0.25f, 0.25f, 0.9f));
+
+ // Chart textures
+ ChartBg = CreateColorPixel(new Color(0.1f, 0.1f, 0.1f, 0.8f));
+ ChartGridMajor = CreateColorPixel(new Color(0.3f, 0.3f, 0.3f, 0.4f));
+ ChartGridMinor = CreateColorPixel(new Color(0.25f, 0.25f, 0.25f, 0.2f));
+ ChartGreenZone = CreateColorPixel(new Color(0.2f, 0.5f, 0.2f, 0.15f));
+ ChartYellowZone = CreateColorPixel(new Color(0.5f, 0.5f, 0.2f, 0.15f));
+ ChartRedZone = CreateColorPixel(new Color(0.5f, 0.2f, 0.2f, 0.15f));
+ ChartDarkRedZone = CreateColorPixel(new Color(0.4f, 0.1f, 0.1f, 0.25f));
+ ChartStartupZone = CreateColorPixel(new Color(0.15f, 0.3f, 0.5f, 0.3f));
+ ChartLine = CreateColorPixel(new Color(0.8f, 0.4f, 0.4f, 1f));
+ ChartMarkerBlue = CreateColorPixel(new Color(0.4f, 0.6f, 0.9f, 0.5f));
+ ChartMarkerGreen = CreateColorPixel(new Color(0.3f, 0.8f, 0.3f, 0.5f));
+ ChartMarkerYellow = CreateColorPixel(new Color(0.9f, 0.9f, 0.3f, 0.5f));
+ ChartMarkerOrange = CreateColorPixel(new Color(1f, 0.65f, 0f, 0.5f));
+ ChartMarkerDarkRed = CreateColorPixel(new Color(0.8f, 0.1f, 0.1f, 0.5f));
+ ChartSeparator = CreateColorPixel(new Color(0.6f, 0.6f, 0.6f, 0.5f));
+ ChartHoverLine = CreateColorPixel(new Color(1f, 1f, 1f, 0.4f));
+ ChartOrangeLine = CreateColorPixel(new Color(1f, 0.5f, 0.2f, 1f));
+ ChartGreenLine = CreateColorPixel(new Color(0.3f, 0.9f, 0.3f, 1f));
+ ChartBlueLine = CreateColorPixel(new Color(0.5f, 0.85f, 1.0f, 1f));
+ ChartTooltipBg = CreateColorPixel(new Color(0.1f, 0.1f, 0.1f, 0.95f));
+ InfoPanelBg = CreateColorPixel(new Color(0.12f, 0.12f, 0.12f, 0.9f));
+
+ _initialized = true;
+ }
+
+ ///
+ /// Ensures all textures are valid. Call this before rendering.
+ /// Handles Unity texture destruction on scene changes.
+ ///
+ public void EnsureInitialized()
+ {
+ // Check if any texture was destroyed (Unity does this on scene changes)
+ if (!RowHover || !ChartBg)
+ {
+ Dispose();
+ _initialized = false;
+ Initialize();
+ }
+ }
+
+ private static Texture2D CreateColorPixel(Color color)
+ {
+ Texture2D texture = new Texture2D(1, 1, TextureFormat.RGBA32, false);
+ texture.SetPixel(0, 0, color);
+ texture.Apply();
+ return texture;
+ }
+
+ public void Dispose()
+ {
+ if (_disposed) return;
+
+ // Destroy all textures
+ DestroyTexture(RowHover);
+ DestroyTexture(RowCurrent);
+ DestroyTexture(RowLocked);
+ DestroyTexture(ZebraStripe);
+ DestroyTexture(ColumnSeparator);
+
+ DestroyTexture(ChartBg);
+ DestroyTexture(ChartGridMajor);
+ DestroyTexture(ChartGridMinor);
+ DestroyTexture(ChartGreenZone);
+ DestroyTexture(ChartYellowZone);
+ DestroyTexture(ChartRedZone);
+ DestroyTexture(ChartDarkRedZone);
+ DestroyTexture(ChartStartupZone);
+ DestroyTexture(ChartLine);
+ DestroyTexture(ChartMarkerBlue);
+ DestroyTexture(ChartMarkerGreen);
+ DestroyTexture(ChartMarkerYellow);
+ DestroyTexture(ChartMarkerOrange);
+ DestroyTexture(ChartMarkerDarkRed);
+ DestroyTexture(ChartSeparator);
+ DestroyTexture(ChartHoverLine);
+ DestroyTexture(ChartOrangeLine);
+ DestroyTexture(ChartGreenLine);
+ DestroyTexture(ChartBlueLine);
+ DestroyTexture(ChartTooltipBg);
+ DestroyTexture(InfoPanelBg);
+
+ // Null them out
+ RowHover = RowCurrent = RowLocked = ZebraStripe = ColumnSeparator = null;
+ ChartBg = ChartGridMajor = ChartGridMinor = ChartGreenZone = ChartYellowZone = null;
+ ChartRedZone = ChartDarkRedZone = ChartStartupZone = ChartLine = null;
+ ChartMarkerBlue = ChartMarkerGreen = ChartMarkerYellow = ChartMarkerOrange = ChartMarkerDarkRed = null;
+ ChartSeparator = ChartHoverLine = ChartOrangeLine = ChartGreenLine = ChartBlueLine = null;
+ ChartTooltipBg = InfoPanelBg = null;
+
+ _disposed = true;
+ }
+
+ private void DestroyTexture(Texture2D texture)
+ {
+ if (texture != null)
+ {
+ UnityEngine.Object.Destroy(texture);
+ }
+ }
+
+ ///
+ /// Call this when the game is shutting down or when you want to force cleanup.
+ ///
+ public static void Cleanup()
+ {
+ if (_instance != null)
+ {
+ _instance.Dispose();
+ _instance = null;
+ }
+ }
+ }
+}
diff --git a/Source/RealFuels.csproj b/Source/RealFuels.csproj
index be97f524..bf8bf89f 100644
--- a/Source/RealFuels.csproj
+++ b/Source/RealFuels.csproj
@@ -90,6 +90,15 @@
+
+
+
+
+
+
+
+
+
diff --git a/Source/Utilities/Styles.cs b/Source/Utilities/Styles.cs
index ecff70a6..0fc950dc 100644
--- a/Source/Utilities/Styles.cs
+++ b/Source/Utilities/Styles.cs
@@ -35,9 +35,9 @@ internal static void InitStyles()
styleEditorTooltip.alignment = TextAnchor.MiddleLeft;
styleEditorPanel = new GUIStyle();
- styleEditorPanel.normal.background = CreateColorPixel(new Color32(7,54,66,200));
+ styleEditorPanel.normal.background = CreateColorPixel(new Color32(45,45,45,200));
styleEditorPanel.border = new RectOffset(27, 27, 27, 27);
- styleEditorPanel.padding = new RectOffset(10, 10, 10, 10);
+ styleEditorPanel.padding = new RectOffset(5, 5, 6, 0);
styleEditorPanel.normal.textColor = new Color32(147,161,161,255);
styleEditorPanel.fontSize = 12;
diff --git a/Source/Utilities/TestFlightWrapper.cs b/Source/Utilities/TestFlightWrapper.cs
index 599be013..e1a419ef 100644
--- a/Source/Utilities/TestFlightWrapper.cs
+++ b/Source/Utilities/TestFlightWrapper.cs
@@ -1,23 +1,39 @@
using System;
using System.Linq;
using System.Reflection;
+using UnityEngine;
namespace RealFuels
{
public static class TestFlightWrapper
{
private const BindingFlags tfBindingFlags = BindingFlags.Public | BindingFlags.Static;
+ private const BindingFlags tfInstanceFlags = BindingFlags.Public | BindingFlags.Instance;
private static Type tfInterface = null;
+ private static Type tfCore = null;
private static MethodInfo addInteropValue = null;
+ private static MethodInfo getFlightData = null;
+ private static MethodInfo getMaxData = null;
static TestFlightWrapper()
{
- if (AssemblyLoader.loadedAssemblies.FirstOrDefault(a => a.assembly.GetName().Name == "TestFlightCore")?.assembly is Assembly)
+ if (AssemblyLoader.loadedAssemblies.FirstOrDefault(a => a.assembly.GetName().Name == "TestFlightCore")?.assembly is Assembly tfAssembly)
{
tfInterface = Type.GetType("TestFlightCore.TestFlightInterface, TestFlightCore", false);
- Type[] argumentTypes = new[] { typeof(Part), typeof(string), typeof(string), typeof(string) };
- addInteropValue = tfInterface.GetMethod("AddInteropValue", tfBindingFlags, null, argumentTypes, null);
+ tfCore = Type.GetType("TestFlightCore.TestFlightCore, TestFlightCore", false);
+
+ if (tfInterface != null)
+ {
+ Type[] argumentTypes = new[] { typeof(Part), typeof(string), typeof(string), typeof(string) };
+ addInteropValue = tfInterface.GetMethod("AddInteropValue", tfBindingFlags, null, argumentTypes, null);
+ }
+
+ if (tfCore != null)
+ {
+ getFlightData = tfCore.GetMethod("GetFlightData", tfInstanceFlags);
+ getMaxData = tfCore.GetMethod("GetMaximumData", tfInstanceFlags);
+ }
}
}
@@ -33,5 +49,74 @@ public static void AddInteropValue(Part part, string name, string value, string
catch { }
}
}
+
+ ///
+ /// Gets the current flight data for the active TestFlight configuration on this part.
+ /// Returns -1 if TestFlight is not available or no data found.
+ ///
+ public static float GetCurrentFlightData(Part part)
+ {
+ if (tfCore == null || getFlightData == null || part == null)
+ return -1f;
+
+ try
+ {
+ // Find TestFlightCore module on the part
+ foreach (PartModule pm in part.Modules)
+ {
+ if (pm.GetType() == tfCore || pm.GetType().IsSubclassOf(tfCore))
+ {
+ object result = getFlightData.Invoke(pm, null);
+ if (result is float flightData)
+ return flightData;
+ }
+ }
+ }
+ catch { }
+
+ return -1f;
+ }
+
+ ///
+ /// Gets the maximum data value for the active TestFlight configuration on this part.
+ /// Returns -1 if TestFlight is not available or no data found.
+ ///
+ public static float GetMaximumData(Part part)
+ {
+ if (tfCore == null || getMaxData == null || part == null)
+ return -1f;
+
+ try
+ {
+ // Find TestFlightCore module on the part
+ foreach (PartModule pm in part.Modules)
+ {
+ if (pm.GetType() == tfCore || pm.GetType().IsSubclassOf(tfCore))
+ {
+ object result = getMaxData.Invoke(pm, null);
+ if (result is float maxData)
+ return maxData;
+ }
+ }
+ }
+ catch { }
+
+ return -1f;
+ }
+
+ ///
+ /// Gets the current data percentage (0 to 1) between 0 and max data.
+ /// Returns -1 if TestFlight is not available or no valid data.
+ ///
+ public static float GetDataPercentage(Part part)
+ {
+ float currentData = GetCurrentFlightData(part);
+ float maxData = GetMaximumData(part);
+
+ if (currentData < 0f || maxData <= 0f)
+ return -1f;
+
+ return Mathf.Clamp01(currentData / maxData);
+ }
}
}