Skip to content

Commit 15c2c0e

Browse files
authored
New modding patch : BaseFieldListUseFieldHost (#278)
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` or `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. Sample code demonstrating those use cases is provided in the source file, after the patch.
1 parent fc791dc commit 15c2c0e

File tree

3 files changed

+352
-0
lines changed

3 files changed

+352
-0
lines changed

GameData/KSPCommunityFixes/Settings.cfg

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -498,6 +498,11 @@ KSP_COMMUNITY_FIXES
498498
// To use add `Description` attribute to the field.
499499
KSPFieldEnumDesc = false
500500
501+
// Allow dynamically defining additional BaseFields on a Part or PartModule and having the backing
502+
// field for that BaseField in another class / instance than the targetted Part or Module. Look for
503+
// the examples and documentation in the patch source.
504+
BaseFieldListUseFieldHost = true
505+
501506
// ##########################
502507
// Localization tools
503508
// ##########################

KSPCommunityFixes/KSPCommunityFixes.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,7 @@
159159
<Compile Include="Library\ShaderHelpers.cs" />
160160
<Compile Include="Modding\KSPFieldEnumDesc.cs" />
161161
<Compile Include="Modding\ModUpgradePipeline.cs" />
162+
<Compile Include="Modding\BaseFieldListUseFieldHost.cs" />
162163
<Compile Include="Performance\ForceSyncSceneSwitch.cs" />
163164
<Compile Include="Performance\AsteroidAndCometDrillCache.cs" />
164165
<Compile Include="BugFixes\DoubleCurvePreserveTangents.cs" />
Lines changed: 346 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,346 @@
1+
// #define EXAMPLE_BaseFieldListUseFieldHost
2+
3+
using HarmonyLib;
4+
using System;
5+
using System.Collections.Generic;
6+
using System.Reflection;
7+
using UnityEngine;
8+
9+
/*
10+
The purpose of this patch if to allow BaseField and associated features (PAW controls, persistence, etc) to work when
11+
a custom BaseField is added to a BaseFieldList (ie, a Part or PartModule) with a host instance other than the BaseFieldList
12+
owner. This allow to dynamically add fields defined in another class to a Part or PartModule and to benefit from all the
13+
associated KSP sugar :
14+
- PAW UI controls
15+
- Value and symmetry events
16+
- Automatic persistence on the Part/PartModule hosting the BaseFieldList
17+
18+
Potential use cases for this are either :
19+
- 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.
20+
- Extending external (typically stock) modules with additional PAW UI controls and/or persisted fields.
21+
22+
The whole thing seems actually designed with such a scenario in mind, but for some reason some BaseField and BaseFieldList
23+
methods are using the BaseFieldList.host instance instead of the BaseField.host instance (as for why BaseFieldList has a
24+
"host" at all, I've no idea and this seems to be a design oversight). There is little to no consistency in which host
25+
reference is used, they are even sometimes mixed in the same method. For example, BaseFieldList.Load() uses BaseFieldList.host
26+
in its main body, then calls BaseFieldList.SetOriginalValue() which is relying on BaseField.host.
27+
28+
Changing every place where a `host` reference is acquired to ensure the BaseField.host reference is used allow to use a custom
29+
host instance, and shouldn't result in any behavior change. This being said, the stock code can technically allow a plugin
30+
to instantiate a custom BaseField with a null host and have it kinda functional if that field is only used to SetValue() /
31+
Getvalue(), as long as the field isn't persistent and doesn't have any associated UI_Control, so we implement a fallback
32+
to the stock behavior in this case.
33+
*/
34+
35+
namespace KSPCommunityFixes.Modding
36+
{
37+
[PatchPriority(Order = 0)]
38+
class BaseFieldListUseFieldHost : BasePatch
39+
{
40+
private static AccessTools.FieldRef<object, Callback<object>> BaseField_OnValueModified_FieldRef;
41+
42+
protected override Version VersionMin => new Version(1, 12, 3);
43+
44+
protected override void ApplyPatches()
45+
{
46+
BaseField_OnValueModified_FieldRef = AccessTools.FieldRefAccess<Callback<object>>(typeof(BaseField<KSPField>), nameof(BaseField<KSPField>.OnValueModified));
47+
if (BaseField_OnValueModified_FieldRef == null)
48+
throw new MissingFieldException($"BaseFieldListUseFieldHost patch could not find the BaseField.OnValueModified event backing field");
49+
50+
// Note : fortunately, we don't need to patch the generic BaseField.GetValue<T>(object host) method because it calls
51+
// the non-generic GetValue method
52+
AddPatch(PatchType.Override, AccessTools.FirstMethod(typeof(BaseField<KSPField>), (m) => m.Name == nameof(BaseField<KSPField>.GetValue) && !m.IsGenericMethod), nameof(BaseField_GetValue_Override));
53+
54+
AddPatch(PatchType.Override, typeof(BaseField<KSPField>), nameof(BaseField<KSPField>.SetValue), nameof(BaseField_SetValue_Override));
55+
56+
// BaseField.Read() is a public method called from :
57+
// - BaseFieldList.Load()
58+
// - BaseFieldList.ReadValue() (2 overloads)
59+
// The method is really tiny so there is a potential inlining risk (doesn't happen in my tests, but this stuff can be platform
60+
// dependent). It's only really critical to have BaseFieldList.Load() being patched, the ReadValue() methods are unused in the
61+
// stock codebase, and it is doubtfull anybody would ever call them.
62+
AddPatch(PatchType.Override, typeof(BaseField), nameof(BaseField.Read));
63+
64+
// We also patch BaseFieldList.Load() because :
65+
// - Of the above mentioned inlining risk
66+
// - Because it pass the (arguably wrong) host reference to the UI_Control.Load() method, and even though none
67+
// of the various overloads make use of that argument we might want to be consistent.
68+
AddPatch(PatchType.Override, typeof(BaseFieldList), nameof(BaseFieldList.Load));
69+
}
70+
71+
static object BaseField_GetValue_Override(BaseField<KSPField> instance, object host)
72+
{
73+
try
74+
{
75+
// In case the field host is null, use the parameter
76+
if (ReferenceEquals(instance._host, null))
77+
return instance._fieldInfo.GetValue(host);
78+
79+
// Uses the field host reference instead of the reference passed as a parameter
80+
return instance._fieldInfo.GetValue(instance._host);
81+
}
82+
catch
83+
{
84+
PDebug.Error("Value could not be retrieved from field '" + instance._name + "'");
85+
return null;
86+
}
87+
}
88+
89+
static bool BaseField_SetValue_Override(BaseField<KSPField> instance, object newValue, object host)
90+
{
91+
try
92+
{
93+
// In case the field host is null, use the parameter
94+
if (ReferenceEquals(instance._host, null))
95+
instance._fieldInfo.SetValue(host, newValue);
96+
97+
// Uses the field host reference instead of the reference passed as a parameter
98+
instance._fieldInfo.SetValue(instance._host, newValue);
99+
100+
// Note : since BaseField.OnValueModified is a "field-like event", it is relying on a compiler-generated
101+
// private backing field, and the public event "syntactic sugar" member can only be invoked from the declaring
102+
// class, so we can't directly call "__instance.OnValueModified()" here. Additionally, the compiler has the extremly
103+
// bad taste to name the backing field "OnValueModified" too, resulting in an ambiguous reference if we try to use
104+
// it through the publicized assembly. So we have to resort to creating a FieldRef open delegate for that backing field.
105+
BaseField_OnValueModified_FieldRef(instance).Invoke(newValue);
106+
return true;
107+
}
108+
catch (Exception ex)
109+
{
110+
PDebug.Error(string.Concat("Value '", newValue, "' could not be set to field '", instance._name, "'"));
111+
PDebug.Error(ex.Message + "\n" + ex.StackTrace + "\n" + ex.Data);
112+
return false;
113+
}
114+
}
115+
116+
static void BaseField_Read_Override(BaseField instance, string value, object host)
117+
{
118+
if (ReferenceEquals(instance._host, null))
119+
BaseField.ReadPvt(instance._fieldInfo, value, host);
120+
121+
BaseField.ReadPvt(instance._fieldInfo, value, instance._host);
122+
}
123+
124+
static void BaseFieldList_Load_Override(BaseFieldList instance, ConfigNode node)
125+
{
126+
for (int i = 0; i < node.values.Count; i++)
127+
{
128+
ConfigNode.Value value = node.values[i];
129+
BaseField baseField = instance[value.name];
130+
if (baseField == null || baseField.hasInterface || baseField.uiControlOnly)
131+
continue;
132+
133+
object host = ReferenceEquals(baseField._host, null) ? instance.host : baseField._host;
134+
135+
// The original code calls BaseField.Read() here. We bypass it to avoid
136+
// any inlining risk and call directly the underlying static method.
137+
BaseField.ReadPvt(baseField._fieldInfo, value.value, host);
138+
139+
if (baseField.uiControlFlight.GetType() != typeof(UI_Label))
140+
{
141+
ConfigNode controlNode = node.GetNode(value.name + "_UIFlight");
142+
if (controlNode != null)
143+
baseField.uiControlFlight.Load(controlNode, host);
144+
}
145+
else if (baseField.uiControlEditor.GetType() != typeof(UI_Label))
146+
{
147+
ConfigNode controlNode = node.GetNode(value.name + "_UIEditor");
148+
if (controlNode != null)
149+
baseField.uiControlEditor.Load(controlNode, host);
150+
}
151+
}
152+
for (int j = 0; j < node.nodes.Count; j++)
153+
{
154+
ConfigNode configNode = node.nodes[j];
155+
BaseField baseField = instance[configNode.name];
156+
if (baseField == null || !baseField.hasInterface || baseField.uiControlOnly)
157+
continue;
158+
159+
object host = ReferenceEquals(baseField._host, null) ? instance.host : baseField._host;
160+
161+
object value = baseField.GetValue(host);
162+
if (value == null)
163+
continue;
164+
165+
(value as IConfigNode)?.Load(configNode);
166+
167+
if (baseField.uiControlFlight.GetType() != typeof(UI_Label))
168+
{
169+
ConfigNode controlNode = node.GetNode(configNode.name + "_UIFlight");
170+
if (controlNode != null)
171+
baseField.uiControlFlight.Load(controlNode, host);
172+
}
173+
else if (baseField.uiControlEditor.GetType() != typeof(UI_Label))
174+
{
175+
ConfigNode controlNode = node.GetNode(configNode.name + "_UIEditor");
176+
if (controlNode != null)
177+
baseField.uiControlEditor.Load(controlNode, host);
178+
}
179+
}
180+
instance.SetOriginalValue();
181+
}
182+
}
183+
184+
#if EXAMPLE_BaseFieldListUseFieldHost
185+
186+
// This is an example module we want to add fields to.
187+
// Here we use the most failsafe and recommended pattern, which is to create and destroy an extension object at the
188+
// earliest and latest possible moment in the module lifecycle.
189+
// In a real use case, this would have to be accomplished by harmony-patching the relevant methods.
190+
// It's technically possible to use other patterns, but tracking parts/modules lifetime externally is full of difficult
191+
// to handle corner-case scenarios, and is likely to cause way more overhead, I really advise against attempting it.
192+
193+
// This mean that adding BaseFields to external modules is only achievable if the methods exist to be patched :
194+
// - A post-instantiation method, ideally OnAwake(), but OnStart()/Start()/OnStartFinished() can work too.
195+
// - OnDestroy(), so the object associated with the external module can be untracked and thus garbage collected
196+
// Without that, the pattern would result in a memory leak. In the absence of an OnDestroy() method, it's possible
197+
// to clean all extensions on scene switches, which still result in a leak, but a temporary one. If you have to
198+
// resort to this, I would advise to consider other options for achieving equivalent functionality, either using a
199+
// new, separate module, or (but that isn't very recommended either) putting the functionality in a derived module
200+
// and MM-swapping it.
201+
202+
// Do note that if you want to define a [KSPField(isPersistant = true)] field and benefit from the built-in persistence,
203+
// your custom BaseField must be added to the target module before PartModule.Load() is called, so the only option is
204+
// to add the BaseField from OnAwake().
205+
206+
public class CustomFieldDemoModule : PartModule
207+
{
208+
public override void OnAwake()
209+
{
210+
CustomFieldDemoModuleExtension.Instantiate(this);
211+
CustomFieldDemoModuleGlobalField.RegisterModule(this);
212+
}
213+
214+
private void OnDestroy()
215+
{
216+
CustomFieldDemoModuleExtension.Destroy(this);
217+
}
218+
}
219+
220+
// This demonstrate adding a field to an existing module, from an object acting as an extension of the module.
221+
// We set the KSPField
222+
public class CustomFieldDemoModuleExtension
223+
{
224+
private static Dictionary<int, CustomFieldDemoModuleExtension> extensions = new Dictionary<int, CustomFieldDemoModuleExtension>();
225+
226+
public static void Instantiate(CustomFieldDemoModule target)
227+
{
228+
// Using the instance ID for the dictionary keys is slightly faster than using the instance itself.
229+
int targetID = target.GetInstanceID();
230+
231+
// Using TryAdd is not necessary if you can guarantee that Instantiate() will never be called more than once for the same
232+
// module, which is a relatively safe assumption if you call it from the module OnAwake() or constructor, and a very unsafe
233+
// one in pretty much every other case (Start, OnStart...)
234+
extensions.TryAdd(targetID, new CustomFieldDemoModuleExtension(target));
235+
}
236+
237+
public static void Destroy(CustomFieldDemoModule target)
238+
{
239+
extensions.Remove(target.GetInstanceID());
240+
}
241+
242+
public static CustomFieldDemoModuleExtension Get(CustomFieldDemoModule module)
243+
{
244+
if (extensions.TryGetValue(module.GetInstanceID(), out CustomFieldDemoModuleExtension extension))
245+
return extension;
246+
247+
return null;
248+
}
249+
250+
private static FieldInfo customFieldInfo;
251+
private static KSPField customKSPField;
252+
private static UI_FloatRange customFieldControl;
253+
254+
static CustomFieldDemoModuleExtension()
255+
{
256+
// we need the FieldInfo for our field, might as well cache it in a static field
257+
customFieldInfo = typeof(CustomFieldDemoModuleExtension).GetField(nameof(customField), BindingFlags.Instance | BindingFlags.NonPublic);
258+
259+
// Here we define static KSPField and UI_FloatRange attributes for our field. This can be also be done
260+
// on a per-instance basis, but this is more performant.
261+
// This would be equivalent to applying the following attributes to a module field :
262+
263+
// [KSPField(guiActive = true, guiActiveEditor = true, guiName = "MyExtensionField", isPersistant = true)]
264+
customKSPField = new KSPField();
265+
customKSPField.guiActive = true;
266+
customKSPField.guiActiveEditor = true;
267+
customKSPField.guiName = "MyExtensionField";
268+
customKSPField.isPersistant = true;
269+
270+
// [UI_FloatRange(minValue = 5f, maxValue = 25f, stepIncrement = 1f, affectSymCounterparts = UI_Scene.All)]
271+
customFieldControl = new UI_FloatRange();
272+
customFieldControl.minValue = 5f;
273+
customFieldControl.maxValue = 25f;
274+
customFieldControl.stepIncrement = 1f;
275+
customFieldControl.affectSymCounterparts = UI_Scene.All;
276+
}
277+
278+
private float customField;
279+
280+
private CustomFieldDemoModuleExtension(CustomFieldDemoModule target)
281+
{
282+
BaseField customBaseField = new BaseField(customKSPField, customFieldInfo, this);
283+
customBaseField.uiControlEditor = customFieldControl;
284+
customBaseField.uiControlFlight = customFieldControl;
285+
target.Fields.Add(customBaseField);
286+
customBaseField.OnValueModified += OnValueModified;
287+
}
288+
289+
private void OnValueModified(object newValue)
290+
{
291+
UnityEngine.Debug.Log($"extension field value changed : {newValue}");
292+
}
293+
}
294+
295+
// This demonstrate having a field on a singleton object, and where all module instances are sharing this
296+
// global field value.
297+
// Note that in this use case, using persistent fields doesn't make any sense, as you would end up with
298+
// conflicting persisted values stored on different modules.
299+
// Also note that in this case, there is no memory leak risk as long as the global instance doesn't keep
300+
// a reference to the modules or BaseFields, so we don't need anything called from the module OnDestroy().
301+
[KSPAddon(KSPAddon.Startup.FlightAndEditor, false)]
302+
public class CustomFieldDemoModuleGlobalField : MonoBehaviour
303+
{
304+
private static CustomFieldDemoModuleGlobalField instance;
305+
private static FieldInfo globalFieldInfo;
306+
private static KSPField globalKSPField;
307+
private static UI_FloatRange globalFieldControl;
308+
309+
static CustomFieldDemoModuleGlobalField()
310+
{
311+
globalFieldInfo = typeof(CustomFieldDemoModuleGlobalField).GetField(nameof(globalField), BindingFlags.Instance | BindingFlags.NonPublic);
312+
globalKSPField = new KSPField();
313+
globalKSPField.guiActive = true;
314+
globalKSPField.guiActiveEditor = true;
315+
globalKSPField.guiName = "MyGlobalField";
316+
globalFieldControl = new UI_FloatRange();
317+
globalFieldControl.minValue = 5f;
318+
globalFieldControl.maxValue = 25f;
319+
globalFieldControl.stepIncrement = 1f;
320+
globalFieldControl.affectSymCounterparts = UI_Scene.None;
321+
}
322+
323+
private void Awake() => instance = this;
324+
private void OnDestroy() => instance = null;
325+
326+
public static void RegisterModule(CustomFieldDemoModule module)
327+
{
328+
if (instance == null)
329+
return;
330+
331+
BaseField globalBaseField = new BaseField(globalKSPField, globalFieldInfo, instance);
332+
globalBaseField.uiControlEditor = globalFieldControl;
333+
globalBaseField.uiControlFlight = globalFieldControl;
334+
module.Fields.Add(globalBaseField);
335+
globalBaseField.OnValueModified += instance.OnValueModified;
336+
}
337+
338+
private float globalField;
339+
340+
private void OnValueModified(object newValue)
341+
{
342+
UnityEngine.Debug.Log($"global field value changed : {newValue}");
343+
}
344+
}
345+
#endif
346+
}

0 commit comments

Comments
 (0)