diff --git a/GameData/KSPCommunityFixes/KSPCommunityFixes.version b/GameData/KSPCommunityFixes/KSPCommunityFixes.version
index 2f73570..1db8d34 100644
--- a/GameData/KSPCommunityFixes/KSPCommunityFixes.version
+++ b/GameData/KSPCommunityFixes/KSPCommunityFixes.version
@@ -2,7 +2,7 @@
"NAME": "KSPCommunityFixes",
"URL": "https://raw.githubusercontent.com/KSPModdingLibs/KSPCommunityFixes/master/GameData/KSPCommunityFixes/KSPCommunityFixes.version",
"DOWNLOAD": "https://github.com/KSPModdingLibs/KSPCommunityFixes/releases",
- "VERSION": {"MAJOR": 1, "MINOR": 21, "PATCH": 1, "BUILD": 0},
+ "VERSION": {"MAJOR": 1, "MINOR": 22, "PATCH": 0, "BUILD": 0},
"KSP_VERSION": {"MAJOR": 1, "MINOR": 12, "PATCH": 3},
"KSP_VERSION_MIN": {"MAJOR": 1, "MINOR": 8, "PATCH": 0},
"KSP_VERSION_MAX": {"MAJOR": 1, "MINOR": 12, "PATCH": 3}
diff --git a/GameData/KSPCommunityFixes/Settings.cfg b/GameData/KSPCommunityFixes/Settings.cfg
index bbeee53..4e55563 100644
--- a/GameData/KSPCommunityFixes/Settings.cfg
+++ b/GameData/KSPCommunityFixes/Settings.cfg
@@ -325,6 +325,11 @@ KSP_COMMUNITY_FIXES
// Fix Admin Building not using HeadImage if that is defined for a Department
DepartmentHeadImage = true
+ // Stores mod versions in sfs and craft files, and uses those versions for the SaveUpgradePipeline,
+ // so mods can do versioning based on their own version numbers and not have to always run their
+ // upgrade scripts.
+ ModUpgradePipeline = false
+
// ##########################
// Localization tools
// ##########################
diff --git a/KSPCommunityFixes/KSPCommunityFixes.csproj b/KSPCommunityFixes/KSPCommunityFixes.csproj
index c1098eb..6fefad1 100644
--- a/KSPCommunityFixes/KSPCommunityFixes.csproj
+++ b/KSPCommunityFixes/KSPCommunityFixes.csproj
@@ -87,6 +87,7 @@
+
@@ -99,6 +100,7 @@
+
diff --git a/KSPCommunityFixes/Modding/ModUpgradePipeline.cs b/KSPCommunityFixes/Modding/ModUpgradePipeline.cs
new file mode 100644
index 0000000..df83b87
--- /dev/null
+++ b/KSPCommunityFixes/Modding/ModUpgradePipeline.cs
@@ -0,0 +1,483 @@
+using System;
+using System.Reflection;
+using System.Collections.Generic;
+using HarmonyLib;
+using SaveUpgradePipeline;
+using UniLinq;
+
+namespace KSPCommunityFixes.Modding
+{
+ class ModUpgradePipeline : BasePatch
+ {
+ protected override Version VersionMin => new Version(1, 8, 0);
+
+ private static string _versionString;
+ private static readonly Dictionary _versionsLoadedString = new Dictionary();
+ private static readonly Dictionary _versionsLoaded = new Dictionary();
+ private static readonly Dictionary _versionsCurrent = new Dictionary();
+ private static readonly Dictionary _versionsTemp = new Dictionary();
+ private static readonly Dictionary _scriptToType = new Dictionary();
+ private static readonly Version _EmptyVersion = new Version(0, 0, 0, 0);
+ private static Assembly _currentAsm = null;
+ private static readonly Assembly _StockAssembly = typeof(UpgradeScript).Assembly;
+
+ // Because the callback is compiler-generated, the callback and the event have the same name.
+ // That means we can't get the callback directly, so we have to get it by reflection.
+ private static FieldInfo OnSetCfgNodeVersionCallback = typeof(SaveUpgradePipeline.SaveUpgradePipeline).GetField(nameof(SaveUpgradePipeline.SaveUpgradePipeline.OnSetCfgNodeVersion), AccessTools.all);
+
+ protected override void ApplyPatches(List patches)
+ {
+ patches.Add(new PatchInfo(
+ PatchMethodType.Postfix,
+ AccessTools.Method(typeof(ShipConstruct), nameof(ShipConstruct.SaveShip)),
+ this));
+
+ patches.Add(new PatchInfo(
+ PatchMethodType.Postfix,
+ AccessTools.Method(typeof(Game), nameof(Game.Save)),
+ this));
+
+ patches.Add(new PatchInfo(
+ PatchMethodType.Prefix,
+ AccessTools.Method(typeof(KSPUpgradePipeline), nameof(KSPUpgradePipeline.Process)),
+ this));
+
+ patches.Add(new PatchInfo(
+ PatchMethodType.Postfix,
+ AccessTools.Method(typeof(SaveUpgradePipeline.SaveUpgradePipeline), nameof(SaveUpgradePipeline.SaveUpgradePipeline.Init)),
+ this, "SaveUpgradePipeline_Init_Postfix"));
+
+ patches.Add(new PatchInfo(
+ PatchMethodType.Prefix,
+ AccessTools.Method(typeof(SaveUpgradePipeline.SaveUpgradePipeline), nameof(SaveUpgradePipeline.SaveUpgradePipeline.SanityCheck)),
+ this, "SaveUpgradePipeline_SanityCheck_Prefix"));
+
+ patches.Add(new PatchInfo(
+ PatchMethodType.Prefix,
+ AccessTools.Method(typeof(SaveUpgradePipeline.SaveUpgradePipeline), nameof(SaveUpgradePipeline.SaveUpgradePipeline.RunIteration)),
+ this, "SaveUpgradePipeline_RunIteration_Prefix"));
+
+ // For some reason this method is failing to be found normally.
+ // So we'll find it manually.
+ MethodBase runMethod = null;
+ foreach (var m in typeof(SaveUpgradePipeline.SaveUpgradePipeline).GetMethods(BindingFlags.Instance | BindingFlags.Public))
+ {
+ if (m.Name == "Run")
+ {
+ runMethod = m;
+ break;
+ }
+ }
+ patches.Add(new PatchInfo(
+ PatchMethodType.Postfix,
+ runMethod,
+ this, "SaveUpgradePipeline_Run_Postfix"));
+
+ patches.Add(new PatchInfo(
+ PatchMethodType.Prefix,
+ AccessTools.Method(typeof(UpgradeScript), nameof(UpgradeScript.Test)),
+ this, "UpgradeScript_Test_Prefix"));
+
+ SaveCurrentVersions();
+
+ // Find event field, again have to do this manually??
+ //foreach (FieldInfo fi in typeof(SaveUpgradePipeline.SaveUpgradePipeline).GetFields(AccessTools.all))
+ //{
+ // if (fi.Name == "OnSetCfgNodeVersion")
+ // {
+ // OnSetCfgNodeVersionCallback = fi;
+ // break;
+ // }
+ //}
+ }
+
+ private static void SaveCurrentVersions()
+ {
+ var sb = StringBuilderCache.Acquire();
+ int aCount = 0;
+
+ foreach (var assembly in AssemblyLoader.loadedAssemblies)
+ {
+ var asm = assembly.assembly;
+ if (asm == _StockAssembly)
+ continue;
+
+ var asmV = asm.GetName().Version;
+ int vMajor = asmV.Major;
+ int vMinor = asmV.Minor;
+ int vBuild = asmV.Build;
+ if (vBuild < 0)
+ vBuild = 0;
+
+ // We prefer AssemblyFileVersion to AssemblyVersion
+ var fileVersionInfo = System.Diagnostics.FileVersionInfo.GetVersionInfo(asm.Location);
+ string fv = fileVersionInfo.FileVersion;
+ if (!string.IsNullOrWhiteSpace(fv) && fv != asmV.ToString())
+ {
+ var fvSplit = fv.Split('.');
+ if (fvSplit.Length > 1) // ignore bogus versions
+ {
+ int tmp;
+ if (int.TryParse(fvSplit[0], out tmp))
+ {
+ vMajor = tmp;
+ if (int.TryParse(fvSplit[1], out tmp))
+ {
+ vMinor = tmp;
+ tmp = 0;
+ if (fvSplit.Length > 2 && !int.TryParse(fvSplit[2], out tmp))
+ {
+ List chars = new List();
+ tmp = 0;
+ for (int i = 0; i < fvSplit[2].Length; ++i)
+ {
+ if (char.IsDigit(fvSplit[2][i]))
+ {
+ tmp *= 10;
+ tmp += (int)char.GetNumericValue(fvSplit[2][i]);
+ }
+ }
+ }
+ vBuild = tmp;
+ }
+ }
+ }
+ }
+ if (aCount++ > 0)
+ sb.Append("|");
+ sb.Append(asm.GetName().Name);
+ sb.Append("=");
+ sb.Append(vMajor);
+ sb.Append(".");
+ sb.Append(vMinor);
+ sb.Append(".");
+ sb.Append(vBuild);
+ _versionsCurrent[asm] = new Version(vMajor, vMinor, vBuild);
+ }
+ _versionString = sb.ToStringAndRelease();
+ }
+
+ private static Version GetVersion(Assembly asm)
+ {
+ if (_versionsLoaded.TryGetValue(asm, out Version v))
+ return v;
+
+ if (!_versionsLoadedString.TryGetValue(asm.GetName().Name, out v))
+ v = _EmptyVersion;
+
+ _versionsLoaded[asm] = v;
+ return v;
+ }
+
+ private static void AddVersions(ConfigNode node, LoadContext loadContext)
+ {
+ if(loadContext == LoadContext.SFS)
+ node.GetNode("GAME").SetValue("_modVersions", _versionString, true);
+ else
+ node.SetValue("_modVersions", _versionString, true);
+ }
+
+ private static void ShipConstruct_SaveShip_Postfix(ref ConfigNode __result)
+ {
+ //UnityEngine.Debug.Log("$$ Saving versions to craft file");
+ AddVersions(__result, LoadContext.Craft);
+ }
+
+ private static void Game_Save_Postfix(ConfigNode rootNode)
+ {
+ //UnityEngine.Debug.Log("$$ Saving versions to sfs file");
+ AddVersions(rootNode, LoadContext.SFS);
+ }
+
+ private static bool TryLoadVersions(ConfigNode n, LoadContext loadContext)
+ {
+ _versionsLoadedString.Clear();
+ _versionsLoaded.Clear();
+
+ if (n != null)
+ return false;
+
+ string versionStr;
+ if (loadContext == LoadContext.SFS)
+ versionStr = n.GetNode("GAME")?.GetValue("_modVersions");
+ else
+ versionStr = n.GetValue("_modVersions");
+
+ if (versionStr == null)
+ return false;
+
+ var allSplit = versionStr.Split('|');
+ foreach (var s in allSplit)
+ {
+ //UnityEngine.Debug.Log("$$ Found version string " + s);
+ var kvp = s.Split('=');
+ if (kvp.Length == 2)
+ {
+ Version v = new Version(kvp[1]);
+ _versionsLoadedString[kvp[0]] = v;
+ }
+ }
+ //UnityEngine.Debug.Log($"$$ Loaded {allSplit.Length} mod versions");
+ return true;
+ }
+
+ private static void KSPUpgradePipeline_Process_Prefix(ConfigNode n, LoadContext loadContext)
+ {
+ TryLoadVersions(n, loadContext);
+ //UnityEngine.Debug.Log("$$ Ready to process.");
+ }
+
+ private static void SaveUpgradePipeline_Init_Postfix(SaveUpgradePipeline.SaveUpgradePipeline __instance)
+ {
+ _scriptToType.Clear();
+ foreach (var uSc in __instance.upgradeScripts)
+ {
+ Type t = uSc.GetType();
+ if (t.Assembly != _StockAssembly)
+ _scriptToType[uSc] = t;
+ }
+ }
+
+ private static bool SaveUpgradePipeline_SanityCheck_Prefix(UpgradeScript uSC, Version AppVersion, out bool __result)
+ {
+ if (uSC.TargetVersion <= uSC.EarliestCompatibleVersion)
+ {
+ UnityEngine.Debug.LogError("[SaveUpgradePipeline]: A script's target version should never be LEqual to its earliest-compat version. " + uSC.Name + " will be skipped.");
+ __result = false;
+ }
+ else
+ {
+ _scriptToType.TryGetValue(uSC, out var usType);
+ Version v = AppVersion;
+ bool isStock = usType == null;
+ if (!isStock)
+ {
+ v = GetVersion(usType.Assembly);
+ }
+ if (v != _EmptyVersion && (uSC.TargetVersion > v || uSC.EarliestCompatibleVersion > v))
+ {
+ UnityEngine.Debug.LogError("[SaveUpgradePipeline]: A script's versions should never exceed the current " + (isStock ? "application" : "mod") + " version. " + uSC.Name + " will be skipped.");
+ __result = false;
+ }
+ else
+ {
+ __result = true;
+ }
+ }
+ return false;
+ }
+
+ private static void SetAssembly(UpgradeScript uSc)
+ {
+ // Set the current assembly for use in overriding version,
+ // if it's not a stock type
+ _scriptToType.TryGetValue(uSc, out var type);
+ if (type != null)
+ _currentAsm = type.Assembly;
+ }
+
+ private static bool SaveUpgradePipeline_RunIteration_Prefix(SaveUpgradePipeline.SaveUpgradePipeline __instance, ConfigNode srcNode, ref ConfigNode node, LoadContext ctx, List scripts, List> log, out IterationResult __result)
+ {
+ Dictionary lastRow = ((log.Count > 0) ? log[log.Count - 1] : null);
+ Dictionary row = new Dictionary();
+ log.Add(row);
+ ConfigNode curNode = node ?? srcNode;
+ for(int i = scripts.Count; i-- > 0;)
+ {
+ // Change: set assembly so VersionTest will use the right version
+ var uSc = scripts[i];
+ SetAssembly(uSc);
+ var testResult = __instance.RunTest(uSc, curNode, ctx);
+ _currentAsm = null;
+ row.Add(uSc, new LogEntry(testResult, upgraded: false));
+ }
+
+ if (row.Values.All((LogEntry r) => r.testResult == TestResult.Pass))
+ {
+ __result = IterationResult.Pass;
+ return false;
+ }
+ if (row.Values.All((LogEntry r) => r.testResult == TestResult.TooEarly))
+ {
+ __result = IterationResult.Fail;
+ return false;
+ }
+ if (!SaveUpgradePipeline.SaveUpgradePipeline.TestExceptionCases(log))
+ {
+ __result = IterationResult.Fail;
+ return false;
+ }
+ if (node == null)
+ {
+ //UnityEngine.Debug.Log("$$ Creating copy of node");
+ node = srcNode.CreateCopy();
+ }
+ for(int i = scripts.Count; i-- > 0;)
+ {
+ if (row[scripts[i]].testResult == TestResult.Upgradeable)
+ {
+ if (lastRow != null && lastRow[scripts[i]].upgraded)
+ {
+ row[scripts[i]].testResult = TestResult.Pass;
+ row[scripts[i]].upgraded = true;
+ }
+ else
+ {
+ // Change: Set assembly just in case (not used yet)
+ var uSc = scripts[i];
+ SetAssembly(uSc);
+ node = __instance.RunUpgrade(uSc, node, ctx);
+ _currentAsm = null;
+ row[uSc].upgraded = true;
+ }
+ }
+ }
+ // Change: we have to handle stock pipelines and mod pipelines differently.
+ __instance.lowestVersion = new Version(int.MaxValue, int.MaxValue, int.MaxValue);
+ bool foundStock = false; // keep track of whether we need to set the game version
+ UpgradeScript[] currentUpgrades = row.Keys.Where((UpgradeScript usc) => row[usc].testResult == TestResult.Upgradeable || row[usc].testResult == TestResult.TooEarly || row[usc].upgraded).ToArray();
+ _versionsTemp.Clear();
+ for(int i = currentUpgrades.Length; i-- > 0;)
+ {
+ // Branch based on whether it's a stock script or a mod script.
+ var uSc = currentUpgrades[i];
+ if (_scriptToType.TryGetValue(uSc, out var t))
+ {
+ // if it's a mod script, get the current-lowest version (it will fail to find
+ // if we haven't set one yet). If it's higher than this version, or doesn't
+ // exist yet, set lowest version to this.
+ Version version = row[uSc].testResult == TestResult.TooEarly ? uSc.EarliestCompatibleVersion : uSc.TargetVersion;
+ if (!_versionsTemp.TryGetValue(t.Assembly, out var lowest) || version < lowest)
+ _versionsTemp[t.Assembly] = version;
+ }
+ else
+ {
+ // Unchanged stock code, except we set a flag to tell us we need
+ // to set the stock cfg version.
+ Version version = row[uSc].testResult == TestResult.TooEarly ? uSc.EarliestCompatibleVersion : uSc.TargetVersion;
+ if (version < __instance.lowestVersion)
+ {
+ __instance.lowestVersion = version;
+ foundStock = true;
+ }
+ }
+ }
+
+ // if we found a stock script, we need to set the cfg version.
+ if (foundStock)
+ {
+ // We can't directly call the event. This is gross.
+ // Further, we're going to reget the field now, because there's no guarantee
+ // that nobody else has added to the callback after this class was instantiated.
+ Callback callback = (Callback)OnSetCfgNodeVersionCallback.GetValue(__instance);
+ callback(node, ctx, __instance.lowestVersion);
+ }
+
+ // If we found mod scripts that need to run again, update their mods' versions here.
+ foreach (var kvp in _versionsTemp)
+ {
+ _versionsLoaded[kvp.Key] = kvp.Value;
+ // We don't need the safe approach, because we know (a)
+ // that we ran the script, so we ran SetAssembly so we put
+ // a kvp in _versionsLoaded, and (b) we know the new version
+ // is higher than the existing version because if it weren't
+ // the script would not have run.
+ //if (_versionsLoaded.TryGetValue(kvp.Key, out var v) && v < kvp.Value)
+ //{
+ // _versionsLoaded[kvp.Key] = kvp.Value;
+ //}
+ }
+
+ __result = IterationResult.Continue;
+ return false;
+ }
+
+ private static void SaveUpgradePipeline_Run_Postfix(ConfigNode node, LoadContext ctx, ref ConfigNode __result)
+ {
+ // Only do this for craft.
+ if (ctx != LoadContext.Craft)
+ return;
+
+ if (__result == node)
+ {
+ // this is annoyingly expensive, but eh.
+ // We need to check if the node already has all loaded assemblies with upgrades
+ // and that the versions equal the current loaded verions. We can short-circuit
+ // by testing count: if we have more assemblies than the node, by definition we
+ // can't match. We need to do this because otherwise we'll re-run the upgrade
+ // pipeline every time we reload this craft.
+ if (TryLoadVersions(__result, ctx) && _versionsCurrent.Count <= _versionsLoadedString.Count)
+ {
+ bool ok = true;
+ foreach (var kvp in _versionsCurrent)
+ {
+ if (!_versionsLoadedString.TryGetValue(kvp.Key.GetName().Name, out var v) || v < kvp.Value)
+ {
+ ok = false;
+ break;
+ }
+ }
+ if (ok)
+ return;
+ }
+ // If we got here, versions are unequal or missing
+ __result = __result.CreateCopy(); // so it gets resaved
+ }
+ // else is unecessary; if they're not equal, we don't have to copy and we can just stomp versions in-place
+
+ AddVersions(__result, ctx);
+ }
+
+ private static bool UpgradeScript_Test_Prefix(UpgradeScript __instance, ConfigNode n, LoadContext loadContext, out TestResult __result)
+ {
+ Version v;
+ if (_currentAsm == null)
+ {
+ v = __instance.GetCfgNodeVersion(n, loadContext);
+ }
+ else
+ {
+ v = GetVersion(_currentAsm);
+ UnityEngine.Debug.Log($"[KSPCommunityFixes] Testing UpgradeScript {_scriptToType[__instance].Name} from assembly {_scriptToType[__instance].Assembly.GetName().Name}, using version {v}");
+ }
+ TestResult tRst = __instance.VersionTest(v);
+ if (tRst != TestResult.Upgradeable)
+ {
+ __result = tRst;
+ return false;
+ }
+ string nodeName = string.Empty;
+ string nodeURL = __instance.GetNodeURL(loadContext);
+ if (string.IsNullOrEmpty(nodeURL))
+ {
+ tRst = __instance.OnTest(n, loadContext, ref nodeName);
+ __instance.LogTestResults(nodeName, tRst);
+ __result = tRst;
+ return false;
+ }
+ tRst = TestResult.Pass;
+ __instance.RecurseNodes(n, nodeURL.Split('/'), 0, delegate (ConfigNode node, ConfigNode parent)
+ {
+ nodeName = string.Empty;
+ TestResult testResult = __instance.OnTest(node, loadContext, ref nodeName);
+ __instance.LogTestResults(nodeName, testResult);
+ switch (testResult)
+ {
+ case TestResult.TooEarly:
+ throw new InvalidOperationException("Script-Level testing shouldn't return TooEarly. This value is only meaningful for Version testing. Override VersionTest if necessary.");
+ case TestResult.Upgradeable:
+ if (tRst != TestResult.Pass)
+ {
+ break;
+ }
+ goto case TestResult.Failed;
+ case TestResult.Failed:
+ tRst = testResult;
+ break;
+ }
+ });
+ __result = tRst;
+ return false;
+ }
+ }
+}
diff --git a/KSPCommunityFixes/Properties/AssemblyInfo.cs b/KSPCommunityFixes/Properties/AssemblyInfo.cs
index 02c54bf..5470807 100644
--- a/KSPCommunityFixes/Properties/AssemblyInfo.cs
+++ b/KSPCommunityFixes/Properties/AssemblyInfo.cs
@@ -29,5 +29,5 @@
// Build Number
// Revision
//
-[assembly: AssemblyVersion("1.21.1.0")]
-[assembly: AssemblyFileVersion("1.21.1.0")]
+[assembly: AssemblyVersion("1.22.0.0")]
+[assembly: AssemblyFileVersion("1.22.0.0")]
diff --git a/README.md b/README.md
index d614174..3e61b08 100644
--- a/README.md
+++ b/README.md
@@ -114,6 +114,7 @@ User options are available from the "ESC" in-game settings menu :
![](
- **PersistentIConfigNode** [KSP 1.8.0 - 1.12.3]<br/>Disabled by default, you can enable it with a MM patch. Implement `IConfigNode` members marked as `[Persistent]` serialization support when using the `CreateObjectFromConfig()`, `LoadObjectFromConfig()` and `CreateConfigFromObject()` methods. Also implements `Guid` serialization support for those methods.
- **ReflectionTypeLoadExceptionHandler** [KSP 1.8.0 - 1.12.3]<br/>Patch the BCL `Assembly.GetTypes()` method to always handle (gracefully) an eventual `ReflectionTypeLoadException`. Since having an assembly failing to load is a quite common scenario, this ensure such a situation won't cause issues with other plugins. Those exceptions are logged (but not re-thrown), and detailed information about offending plugins is shown on screen during loading so users are aware there is an issue with their install. This patch is always enabled and has no entry in `Settings.cfg`.
- **[DepartmentHeadImage](https://github.com/KSPModdingLibs/KSPCommunityFixes/issues/47)** [KSP 1.8.0 - 1.12.3]<br/> Fix administration building custom departement head image not being used when added by a mod.
+- **[ModUpgradePipeline](https://github.com/KSPModdingLibs/KSPCommunityFixes/pull/91)** [KSP 1.8.0 - 1.12.3]<br/>This will save mod versions in sfs and craft files, and use those versions for mods' SaveUpgradePipeline scripts so that mods can handle their own upgrade versioning using native KSP tools instead of having to always run their upgrade scripts.
#### Stock configs tweaks
- **[ManufacturerFixes](https://github.com/KSPModdingLibs/KSPCommunityFixes/issues/62)**<br/>Fix a bunch of stock parts not having manufacturers, add icons for the stock )