diff --git a/GameData/KSPCommunityFixes/Settings.cfg b/GameData/KSPCommunityFixes/Settings.cfg index 8f2d4f9..a2cca41 100644 --- a/GameData/KSPCommunityFixes/Settings.cfg +++ b/GameData/KSPCommunityFixes/Settings.cfg @@ -498,6 +498,11 @@ KSP_COMMUNITY_FIXES // To use add `Description` attribute to the field. KSPFieldEnumDesc = false + // Allow dynamically defining additional BaseFields on a Part or PartModule and having the backing + // field for that BaseField in another class / instance than the targetted Part or Module. Look for + // the examples and documentation in the patch source. + BaseFieldListUseFieldHost = true + // ########################## // Localization tools // ########################## diff --git a/KSPCommunityFixes/KSPCommunityFixes.csproj b/KSPCommunityFixes/KSPCommunityFixes.csproj index bd72a13..6774e89 100644 --- a/KSPCommunityFixes/KSPCommunityFixes.csproj +++ b/KSPCommunityFixes/KSPCommunityFixes.csproj @@ -159,6 +159,7 @@ + diff --git a/KSPCommunityFixes/Modding/BaseFieldListUseFieldHost.cs b/KSPCommunityFixes/Modding/BaseFieldListUseFieldHost.cs new file mode 100644 index 0000000..2fede70 --- /dev/null +++ b/KSPCommunityFixes/Modding/BaseFieldListUseFieldHost.cs @@ -0,0 +1,346 @@ +// #define EXAMPLE_BaseFieldListUseFieldHost + +using HarmonyLib; +using System; +using System.Collections.Generic; +using System.Reflection; +using UnityEngine; + +/* +The purpose of this patch if to allow BaseField and associated features (PAW controls, persistence, etc) to work when +a custom BaseField is added to a BaseFieldList (ie, a Part or PartModule) with a host instance other than the BaseFieldList +owner. This allow to dynamically add fields defined in another class to a Part or PartModule and to benefit from all the +associated KSP sugar : +- PAW UI controls +- Value and symmetry events +- Automatic persistence on the Part/PartModule hosting the BaseFieldList + +Potential use cases for this are either : +- Having a part or module-level PAW item associated to and sharing the state of a common field, for example a field in a KSPAddon. +- Extending external (typically stock) modules with additional PAW UI controls and/or persisted fields. + +The whole thing seems actually designed with such a scenario in mind, but for some reason some BaseField and BaseFieldList +methods are using the BaseFieldList.host instance instead of the BaseField.host instance (as for why BaseFieldList has a +"host" at all, I've no idea and this seems to be a design oversight). There is little to no consistency in which host +reference is used, they are even sometimes mixed in the same method. For example, BaseFieldList.Load() uses BaseFieldList.host +in its main body, then calls BaseFieldList.SetOriginalValue() which is relying on BaseField.host. + +Changing every place where a `host` reference is acquired to ensure the BaseField.host reference is used allow to use a custom +host instance, and shouldn't result in any behavior change. This being said, the stock code can technically allow a plugin +to instantiate a custom BaseField with a null host and have it kinda functional if that field is only used to SetValue() / +Getvalue(), as long as the field isn't persistent and doesn't have any associated UI_Control, so we implement a fallback +to the stock behavior in this case. +*/ + +namespace KSPCommunityFixes.Modding +{ + [PatchPriority(Order = 0)] + class BaseFieldListUseFieldHost : BasePatch + { + private static AccessTools.FieldRef> BaseField_OnValueModified_FieldRef; + + protected override Version VersionMin => new Version(1, 12, 3); + + protected override void ApplyPatches() + { + BaseField_OnValueModified_FieldRef = AccessTools.FieldRefAccess>(typeof(BaseField), nameof(BaseField.OnValueModified)); + if (BaseField_OnValueModified_FieldRef == null) + throw new MissingFieldException($"BaseFieldListUseFieldHost patch could not find the BaseField.OnValueModified event backing field"); + + // Note : fortunately, we don't need to patch the generic BaseField.GetValue(object host) method because it calls + // the non-generic GetValue method + AddPatch(PatchType.Override, AccessTools.FirstMethod(typeof(BaseField), (m) => m.Name == nameof(BaseField.GetValue) && !m.IsGenericMethod), nameof(BaseField_GetValue_Override)); + + AddPatch(PatchType.Override, typeof(BaseField), nameof(BaseField.SetValue), nameof(BaseField_SetValue_Override)); + + // BaseField.Read() is a public method called from : + // - BaseFieldList.Load() + // - BaseFieldList.ReadValue() (2 overloads) + // The method is really tiny so there is a potential inlining risk (doesn't happen in my tests, but this stuff can be platform + // dependent). It's only really critical to have BaseFieldList.Load() being patched, the ReadValue() methods are unused in the + // stock codebase, and it is doubtfull anybody would ever call them. + AddPatch(PatchType.Override, typeof(BaseField), nameof(BaseField.Read)); + + // We also patch BaseFieldList.Load() because : + // - Of the above mentioned inlining risk + // - Because it pass the (arguably wrong) host reference to the UI_Control.Load() method, and even though none + // of the various overloads make use of that argument we might want to be consistent. + AddPatch(PatchType.Override, typeof(BaseFieldList), nameof(BaseFieldList.Load)); + } + + static object BaseField_GetValue_Override(BaseField instance, object host) + { + try + { + // In case the field host is null, use the parameter + if (ReferenceEquals(instance._host, null)) + return instance._fieldInfo.GetValue(host); + + // Uses the field host reference instead of the reference passed as a parameter + return instance._fieldInfo.GetValue(instance._host); + } + catch + { + PDebug.Error("Value could not be retrieved from field '" + instance._name + "'"); + return null; + } + } + + static bool BaseField_SetValue_Override(BaseField instance, object newValue, object host) + { + try + { + // In case the field host is null, use the parameter + if (ReferenceEquals(instance._host, null)) + instance._fieldInfo.SetValue(host, newValue); + + // Uses the field host reference instead of the reference passed as a parameter + instance._fieldInfo.SetValue(instance._host, newValue); + + // Note : since BaseField.OnValueModified is a "field-like event", it is relying on a compiler-generated + // private backing field, and the public event "syntactic sugar" member can only be invoked from the declaring + // class, so we can't directly call "__instance.OnValueModified()" here. Additionally, the compiler has the extremly + // bad taste to name the backing field "OnValueModified" too, resulting in an ambiguous reference if we try to use + // it through the publicized assembly. So we have to resort to creating a FieldRef open delegate for that backing field. + BaseField_OnValueModified_FieldRef(instance).Invoke(newValue); + return true; + } + catch (Exception ex) + { + PDebug.Error(string.Concat("Value '", newValue, "' could not be set to field '", instance._name, "'")); + PDebug.Error(ex.Message + "\n" + ex.StackTrace + "\n" + ex.Data); + return false; + } + } + + static void BaseField_Read_Override(BaseField instance, string value, object host) + { + if (ReferenceEquals(instance._host, null)) + BaseField.ReadPvt(instance._fieldInfo, value, host); + + BaseField.ReadPvt(instance._fieldInfo, value, instance._host); + } + + static void BaseFieldList_Load_Override(BaseFieldList instance, ConfigNode node) + { + for (int i = 0; i < node.values.Count; i++) + { + ConfigNode.Value value = node.values[i]; + BaseField baseField = instance[value.name]; + if (baseField == null || baseField.hasInterface || baseField.uiControlOnly) + continue; + + object host = ReferenceEquals(baseField._host, null) ? instance.host : baseField._host; + + // The original code calls BaseField.Read() here. We bypass it to avoid + // any inlining risk and call directly the underlying static method. + BaseField.ReadPvt(baseField._fieldInfo, value.value, host); + + if (baseField.uiControlFlight.GetType() != typeof(UI_Label)) + { + ConfigNode controlNode = node.GetNode(value.name + "_UIFlight"); + if (controlNode != null) + baseField.uiControlFlight.Load(controlNode, host); + } + else if (baseField.uiControlEditor.GetType() != typeof(UI_Label)) + { + ConfigNode controlNode = node.GetNode(value.name + "_UIEditor"); + if (controlNode != null) + baseField.uiControlEditor.Load(controlNode, host); + } + } + for (int j = 0; j < node.nodes.Count; j++) + { + ConfigNode configNode = node.nodes[j]; + BaseField baseField = instance[configNode.name]; + if (baseField == null || !baseField.hasInterface || baseField.uiControlOnly) + continue; + + object host = ReferenceEquals(baseField._host, null) ? instance.host : baseField._host; + + object value = baseField.GetValue(host); + if (value == null) + continue; + + (value as IConfigNode)?.Load(configNode); + + if (baseField.uiControlFlight.GetType() != typeof(UI_Label)) + { + ConfigNode controlNode = node.GetNode(configNode.name + "_UIFlight"); + if (controlNode != null) + baseField.uiControlFlight.Load(controlNode, host); + } + else if (baseField.uiControlEditor.GetType() != typeof(UI_Label)) + { + ConfigNode controlNode = node.GetNode(configNode.name + "_UIEditor"); + if (controlNode != null) + baseField.uiControlEditor.Load(controlNode, host); + } + } + instance.SetOriginalValue(); + } + } + +#if EXAMPLE_BaseFieldListUseFieldHost + + // This is an example module we want to add fields to. + // Here we use the most failsafe and recommended pattern, which is to create and destroy an extension object at the + // earliest and latest possible moment in the module lifecycle. + // In a real use case, this would have to be accomplished by harmony-patching the relevant methods. + // It's technically possible to use other patterns, but tracking parts/modules lifetime externally is full of difficult + // to handle corner-case scenarios, and is likely to cause way more overhead, I really advise against attempting it. + + // This mean that adding BaseFields to external modules is only achievable if the methods exist to be patched : + // - A post-instantiation method, ideally OnAwake(), but OnStart()/Start()/OnStartFinished() can work too. + // - OnDestroy(), so the object associated with the external module can be untracked and thus garbage collected + // Without that, the pattern would result in a memory leak. In the absence of an OnDestroy() method, it's possible + // to clean all extensions on scene switches, which still result in a leak, but a temporary one. If you have to + // resort to this, I would advise to consider other options for achieving equivalent functionality, either using a + // new, separate module, or (but that isn't very recommended either) putting the functionality in a derived module + // and MM-swapping it. + + // Do note that if you want to define a [KSPField(isPersistant = true)] field and benefit from the built-in persistence, + // your custom BaseField must be added to the target module before PartModule.Load() is called, so the only option is + // to add the BaseField from OnAwake(). + + public class CustomFieldDemoModule : PartModule + { + public override void OnAwake() + { + CustomFieldDemoModuleExtension.Instantiate(this); + CustomFieldDemoModuleGlobalField.RegisterModule(this); + } + + private void OnDestroy() + { + CustomFieldDemoModuleExtension.Destroy(this); + } + } + + // This demonstrate adding a field to an existing module, from an object acting as an extension of the module. + // We set the KSPField + public class CustomFieldDemoModuleExtension + { + private static Dictionary extensions = new Dictionary(); + + public static void Instantiate(CustomFieldDemoModule target) + { + // Using the instance ID for the dictionary keys is slightly faster than using the instance itself. + int targetID = target.GetInstanceID(); + + // Using TryAdd is not necessary if you can guarantee that Instantiate() will never be called more than once for the same + // module, which is a relatively safe assumption if you call it from the module OnAwake() or constructor, and a very unsafe + // one in pretty much every other case (Start, OnStart...) + extensions.TryAdd(targetID, new CustomFieldDemoModuleExtension(target)); + } + + public static void Destroy(CustomFieldDemoModule target) + { + extensions.Remove(target.GetInstanceID()); + } + + public static CustomFieldDemoModuleExtension Get(CustomFieldDemoModule module) + { + if (extensions.TryGetValue(module.GetInstanceID(), out CustomFieldDemoModuleExtension extension)) + return extension; + + return null; + } + + private static FieldInfo customFieldInfo; + private static KSPField customKSPField; + private static UI_FloatRange customFieldControl; + + static CustomFieldDemoModuleExtension() + { + // we need the FieldInfo for our field, might as well cache it in a static field + customFieldInfo = typeof(CustomFieldDemoModuleExtension).GetField(nameof(customField), BindingFlags.Instance | BindingFlags.NonPublic); + + // Here we define static KSPField and UI_FloatRange attributes for our field. This can be also be done + // on a per-instance basis, but this is more performant. + // This would be equivalent to applying the following attributes to a module field : + + // [KSPField(guiActive = true, guiActiveEditor = true, guiName = "MyExtensionField", isPersistant = true)] + customKSPField = new KSPField(); + customKSPField.guiActive = true; + customKSPField.guiActiveEditor = true; + customKSPField.guiName = "MyExtensionField"; + customKSPField.isPersistant = true; + + // [UI_FloatRange(minValue = 5f, maxValue = 25f, stepIncrement = 1f, affectSymCounterparts = UI_Scene.All)] + customFieldControl = new UI_FloatRange(); + customFieldControl.minValue = 5f; + customFieldControl.maxValue = 25f; + customFieldControl.stepIncrement = 1f; + customFieldControl.affectSymCounterparts = UI_Scene.All; + } + + private float customField; + + private CustomFieldDemoModuleExtension(CustomFieldDemoModule target) + { + BaseField customBaseField = new BaseField(customKSPField, customFieldInfo, this); + customBaseField.uiControlEditor = customFieldControl; + customBaseField.uiControlFlight = customFieldControl; + target.Fields.Add(customBaseField); + customBaseField.OnValueModified += OnValueModified; + } + + private void OnValueModified(object newValue) + { + UnityEngine.Debug.Log($"extension field value changed : {newValue}"); + } + } + + // This demonstrate having a field on a singleton object, and where all module instances are sharing this + // global field value. + // Note that in this use case, using persistent fields doesn't make any sense, as you would end up with + // conflicting persisted values stored on different modules. + // Also note that in this case, there is no memory leak risk as long as the global instance doesn't keep + // a reference to the modules or BaseFields, so we don't need anything called from the module OnDestroy(). + [KSPAddon(KSPAddon.Startup.FlightAndEditor, false)] + public class CustomFieldDemoModuleGlobalField : MonoBehaviour + { + private static CustomFieldDemoModuleGlobalField instance; + private static FieldInfo globalFieldInfo; + private static KSPField globalKSPField; + private static UI_FloatRange globalFieldControl; + + static CustomFieldDemoModuleGlobalField() + { + globalFieldInfo = typeof(CustomFieldDemoModuleGlobalField).GetField(nameof(globalField), BindingFlags.Instance | BindingFlags.NonPublic); + globalKSPField = new KSPField(); + globalKSPField.guiActive = true; + globalKSPField.guiActiveEditor = true; + globalKSPField.guiName = "MyGlobalField"; + globalFieldControl = new UI_FloatRange(); + globalFieldControl.minValue = 5f; + globalFieldControl.maxValue = 25f; + globalFieldControl.stepIncrement = 1f; + globalFieldControl.affectSymCounterparts = UI_Scene.None; + } + + private void Awake() => instance = this; + private void OnDestroy() => instance = null; + + public static void RegisterModule(CustomFieldDemoModule module) + { + if (instance == null) + return; + + BaseField globalBaseField = new BaseField(globalKSPField, globalFieldInfo, instance); + globalBaseField.uiControlEditor = globalFieldControl; + globalBaseField.uiControlFlight = globalFieldControl; + module.Fields.Add(globalBaseField); + globalBaseField.OnValueModified += instance.OnValueModified; + } + + private float globalField; + + private void OnValueModified(object newValue) + { + UnityEngine.Debug.Log($"global field value changed : {newValue}"); + } + } +#endif +} \ No newline at end of file