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 b570db6..353e322 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 :