From 74cf356bd77785ff445b9de6a407a5c0a63cb392 Mon Sep 17 00:00:00 2001 From: LordMidas <55047920+LordMidas@users.noreply.github.com> Date: Thu, 16 Apr 2026 21:37:09 -0400 Subject: [PATCH 01/24] feat: validate mods compatibility when trying to load saved games --- msu/hooks/states/world_state.nut | 28 ++- msu/hooks/ui/global/data_helper.nut | 15 ++ msu/msu_mod/msu_tooltips.nut | 25 ++ msu/systems/serialization/saved_mods_info.nut | 230 ++++++++++++++++++ .../serialization/serialization_mod_addon.nut | 4 + .../serialization/serialization_system.nut | 8 + ui/mods/msu/ui_hooks/main_menu_module.js | 12 + 7 files changed, 309 insertions(+), 13 deletions(-) create mode 100644 msu/systems/serialization/saved_mods_info.nut diff --git a/msu/hooks/states/world_state.nut b/msu/hooks/states/world_state.nut index 98a40d5c1..3a7fc8d4c 100644 --- a/msu/hooks/states/world_state.nut +++ b/msu/hooks/states/world_state.nut @@ -197,26 +197,19 @@ q.onBeforeSerialize = @(__original) function( _out ) { __original(_out); - local meta = _out.getMetaData(); - local modIDsString = ""; - foreach (mod in ::MSU.System.Serialization.Mods) - { - meta.setString(mod.getID() + "Version", mod.getVersionString()); - ::MSU.Mod.Debug.printLog(format("MSU Serialization: Saving %s (%s), Version: %s", mod.getName(), mod.getID(), mod.getVersionString())); - } - foreach (mod in ::Hooks.getMods()) modIDsString += mod.getID() + ","; - meta.setString("MSU.SavedModIDs", modIDsString.slice(0, -1)); + ::MSU.Class.SavedModsInfo().saveToMetaData(_out.getMetaData()); } q.onBeforeDeserialize = @(__original) function( _in ) { __original(_in); + local modsInfo = _in.getMetaData().hasData(::MSU.Class.SavedModsInfo.MetaDataStringID) ? ::MSU.Class.SavedModsInfo(_in.getMetaData()) : null; + if (::MSU.Mod.Serialization.isSavedVersionAtLeast("1.1.0", _in.getMetaData())) { - local modIDs = split(_in.getMetaData().getString("MSU.SavedModIDs"), ","); - local hooksMods = ::Hooks.getMods(); - foreach (mod in hooksMods) + local modIDs = modsInfo != null ? ::MSU.Table.keys(modInfos.getMods()) : split(_in.getMetaData().getString("MSU.SavedModIDs"), ","); + foreach (mod in ::Hooks.getMods()) { local IDIdx = modIDs.find(mod.getID()); if (IDIdx != null) @@ -224,7 +217,16 @@ modIDs.remove(IDIdx); if (::MSU.System.Registry.hasMod(mod.getID())) { - local oldVersion = _in.getMetaData().getString(mod.getID() + "Version"); + local oldVersion; + if (modsInfo != null) + { + oldVersion = modsInfo.hasMod(mod.getID()) ? "" : modInfo.getMod(mod.getID()).getVersionString(); + } + else + { + oldVersion = _in.getMetaData().getString(mod.getID() + "Version"); + } + if (oldVersion == "") { ::logInfo(format("MSU Serialization: First time this save has been loaded with an MSU version of %s (%s)", mod.getName(), mod.getID())); diff --git a/msu/hooks/ui/global/data_helper.nut b/msu/hooks/ui/global/data_helper.nut index 1218bc88c..9b0e9f54f 100644 --- a/msu/hooks/ui/global/data_helper.nut +++ b/msu/hooks/ui/global/data_helper.nut @@ -10,5 +10,20 @@ ::PersistenceManager.queryStorages = queryStorages; return ret; } + + q.convertCampaignStorageToUIData = @(__original) { function convertCampaignStorageToUIData( _meta ) + { + local ret = __original(_meta); + // ret.MSU_ModIncompatibility <- []; + + local modsInfo = ::MSU.Class.SavedModsInfo(_meta); + ret.MSU_ModIncompatibility <- modsInfo.validateMods(); + if (ret.MSU_ModIncompatibility.len() != 0) + { + ret.isIncompatibleVersion = true; + } + + return ret; + }}.convertCampaignStorageToUIData ; }); diff --git a/msu/msu_mod/msu_tooltips.nut b/msu/msu_mod/msu_tooltips.nut index 925192e47..3675528ee 100644 --- a/msu/msu_mod/msu_tooltips.nut +++ b/msu/msu_mod/msu_tooltips.nut @@ -1,4 +1,29 @@ ::MSU.Mod.Tooltips.setTooltips({ + LoadCampaign = ::MSU.Class.CustomTooltip(function( _data ) { + local ret = [ + { contentType = "settlement-status-effect"}, + { + id = 1, + type = "title", + text = "Mod Incompatibility" + }, + { + id = 2, + type = "description", + text = "Detected by MSU" + } + ]; + foreach (e in _data.errors) + { + ret.push({ + id = 2, + type = "text", + icon = "ui/icons/warning.png", + text = e + }); + } + return ret; + }), ModSettings = { Main = { Cancel = ::MSU.Class.BasicTooltip("Cancel", "Don't save changes."), diff --git a/msu/systems/serialization/saved_mods_info.nut b/msu/systems/serialization/saved_mods_info.nut new file mode 100644 index 000000000..f3d55fef2 --- /dev/null +++ b/msu/systems/serialization/saved_mods_info.nut @@ -0,0 +1,230 @@ +::MSU.Class.SavedModsInfo <- class +{ + static ModSeparator = "%%%%"; + static ModInfoSeparator = "&&"; + static CompatInfoSeparator = "^^"; + static CompatModSeparator = ","; + static MetaDataStringID = "MSU.SavedModsInfo"; + // Used to pass the required _metadata arg in Hooks Mod constructor + static EmptyTable = {}; + + Mods = null; + + // Pass metadata to load saved mods info from that metadata. + constructor( _metadata = null ) + { + this.Mods = {}; + + if (_metadata == null) + return; + + local info = _metadata.hasData(this.MetaDataStringID) ? _metadata.getString(this.MetaDataStringID) : ""; + if (info == "") + { + this.__loadOldData(_metadata); + return; + } + + foreach (mod in split(info, this.ModSeparator)) + { + local info = split(mod, this.ModInfoSeparator); + local mod = ::Hooks.SQClass.Mod(info[0], info[2], info[1], this.EmptyTable); + if (info[3] != "x") + { + foreach (req in split(info[3], this.CompatModSeparator)) + { + mod.require(this.__getCompatString(req)); + } + } + if (info[4] != "x") + { + foreach (conflict in split(info[4], this.CompatModSeparator)) + { + mod.conflictWith(this.__getCompatString(conflict)); + } + } + + this.Mods[mod.getID()] <- mod; + } + } + + // Used to load saved mod data from MSU 1.8.0 and older. + function __loadOldData( _metadata ) + { + if (!_metadata.hasData("MSU.SavedModIDs")) + return; + + local ids = split(_metadata.getString("MSU.SavedModIDs"), ","); + foreach (id in ids) + { + ::logInfo("Loading old mod: " + id + " Version: " + _metadata.getString(id + "Version")); + this.Mods[id] <- ::Hooks.SQClass.Mod(id, _metadata.getString(id + "Version") == "" ? "1.0.0" : _metadata.getString(id + "Version"), "", this.EmptyTable); + } + } + + function __getCompatString( _str ) + { + local info = split(_str, this.CompatInfoSeparator); + local operator = info[2]; + local version = info[3]; + return format("%s%s%s", info[0], operator == "x" ? "" : " " + operator + " ", version == "x" ? "" : " " + version + " "); + } + + function getMods() + { + return this.Mods; + } + + function hasMod( _id ) + { + return _id in this.Mods; + } + + function getMod( _id ) + { + return this.Mods[_id]; + } + + // Mods can use this to clear compatibility data from existing save for a mod. + // Use Case: You create a compatibility patch for 2 mods + // and want to remove the incompatibility declaration from the saved mod. + // Use Case: You create a fork of a mod and want different compatibility data. + // How to use: After clearing the compatibility data, you should define your own for that mod using + // getMod(_id) and then using the Modern Hooks .require and .conflictWith functions on that mod. + function clearCompatibilityData( _id ) + { + this.Mods[_id].CompatibilityData.clear(); + } + + function validateMods() + { + // We allow mods to adjust the compatibility data before we validate it. + ::MSU.System.Serialization.onValidateSavedMods(this); + + local compatErrors = []; + foreach (mod in this.getMods()) + { + foreach (compatibilityData in mod.getCompatibilityData()) + { + local result = compatibilityData.validate(::Hooks.getMods()); + if (result == ::Hooks.CompatibilityCheckResult.Success) + continue; + compatErrors.push({ + Source = mod, + Target = compatibilityData, + Reason = result, + IsTargetSaved = false + }); + } + } + + foreach (mod in ::Hooks.getMods()) + { + foreach (compatibilityData in mod.getCompatibilityData()) + { + local result = compatibilityData.validate(this.getMods()); + if (result == ::Hooks.CompatibilityCheckResult.Success) + continue; + compatErrors.push({ + Source = mod, + Target = compatibilityData, + Reason = result, + IsTargetSaved = true + }); + } + } + + if (compatErrors.len() == 0) + return compatErrors; + + foreach (i, error in compatErrors) + { + local requireString = error.Target.CompatibilityType == ::Hooks.CompatibilityType.Requirement ? "requires" : "conflicts with"; + switch (error.Reason) + { + case ::Hooks.CompatibilityCheckResult.ModMissing: + local name = error.Target.getModID() in ::Hooks.CachedModNames ? ::Hooks.CachedModNames[error.Target.getModID()] : error.Target.getModName(); + compatErrors[i] = format("%s %s (%s) %s %s %s (%s)%s", error.IsTargetSaved ? "Installed" : "Saved", error.Source.getID(), error.Source.getName(), requireString, error.IsTargetSaved ? "Saved" : "Installed", error.Target.getModID(), name, error.Target.getFormattedDetails()); + break; + case ::Hooks.CompatibilityCheckResult.ModPresent: + local mod = error.IsTargetSaved ? this.getMod(error.Target.getModID()) : ::Hooks.getMod(error.Target.getModID()); + compatErrors[i] = format("%s %s (%s) is incompatible with %s %s (%s)%s", error.IsTargetSaved ? "Saved" : "Installed", error.Source.getID(), error.Source.getName(), error.IsTargetSaved ? "Installed" : "Saved", mod.getID(), mod.getName(), error.Target.getFormattedDetails()); + break; + case ::Hooks.CompatibilityCheckResult.TooSmall: + local mod = error.IsTargetSaved ? this.getMod(error.Target.getModID()) : ::Hooks.getMod(error.Target.getModID()); + compatErrors[i] = format("%s %s (%s) version %s is outdated for %s %s (%s), which %s versions %s", error.IsTargetSaved ? "Saved" : "Installed", mod.getID(), mod.getName(), mod.getVersionString(), error.IsTargetSaved ? "Installed" : "Saved", error.Source.getID(), error.Source.getName(), requireString, error.Target.getErrorString()); + break; + case ::Hooks.CompatibilityCheckResult.TooBig: + local mod = error.IsTargetSaved ? this.getMod(error.Target.getModID()) : ::Hooks.getMod(error.Target.getModID()); + compatErrors[i] = format("%s %s (%s) version %s is too new for %s %s (%s), which %s versions %s", error.IsTargetSaved ? "Saved" : "Installed", mod.getID(), mod.getName(), mod.getVersionString(), error.IsTargetSaved ? "Installed" : "Saved", error.Source.getID(), error.Source.getName(), requireString, error.Target.getErrorString()); + break; + case ::Hooks.CompatibilityCheckResult.Incorrect: + local mod = error.IsTargetSaved ? this.getMod(error.Target.getModID()) : ::Hooks.getMod(error.Target.getModID()); + compatErrors[i] = format("%s %s (%s) version %s is wrong for %s %s (%s), which %s (a) version %s", error.IsTargetSaved ? "Saved" : "Installed", mod.getID(), mod.getName(), mod.getVersionString(), error.IsTargetSaved ? "Installed" : "Saved", error.Source.getID(), error.Source.getName(), requireString, error.Target.getErrorString()); + break; + } + } + + return compatErrors; + } + + function __parseOperatorToString( _operator ) + { + switch (_operator) + { + case ::Hooks.Operator.LT: + return "<"; + case ::Hooks.Operator.LE: + return "<="; + case ::Hooks.Operator.EQ: + return "=="; + case ::Hooks.Operator.NE: + return "!="; + case ::Hooks.Operator.GE: + return ">="; + case ::Hooks.Operator.GT: + return ">"; + } + return ""; + } + + function saveToMetaData( _metadata ) + { + local info = ""; + foreach (mod in ::MSU.System.Serialization.Mods) + { + ::MSU.Mod.Debug.printLog(format("MSU Serialization: Saving %s (%s), Version: %s", mod.getName(), mod.getID(), mod.getVersionString())); + local reqStr = ""; + local conflictStr = ""; + foreach (data in ::Hooks.getMod(mod.getID()).getCompatibilityData()) + { + local str = format("%s%s%s%s%s%s%s", + data.getModID(), this.CompatInfoSeparator, + data.getModName(), this.CompatInfoSeparator, + data.Operator == null ? "x" : data.Operator + "", this.CompatInfoSeparator, + data.Version == null ? "x" : data.Version + ""); + switch (data.CompatibilityType) + { + case ::Hooks.CompatibilityType.Requirement: + reqStr += str + this.CompatModSeparator; + break; + case ::Hooks.CompatibilityType.Incompatibility: + conflictStr += str + this.CompatModSeparator; + break; + } + } + info += format("%s%s%s%s%s%s%s%s%s%s", + mod.getID(), this.ModInfoSeparator, + mod.getName(), this.ModInfoSeparator, + mod.getVersionString(), this.ModInfoSeparator, + reqStr == "" ? "x" : reqStr.slice(0, -1), this.ModInfoSeparator, + conflictStr = "" ? "x" : conflictStr.slice(0, -this.CompatModSeparator.len()), + this.ModSeparator); + } + if (info != "") + { + info.slice(0, -this.ModSeparator.len()); + } + _metadata.setString(this.MetaDataStringID, info); + } +} diff --git a/msu/systems/serialization/serialization_mod_addon.nut b/msu/systems/serialization/serialization_mod_addon.nut index af1a9109f..4948802bf 100644 --- a/msu/systems/serialization/serialization_mod_addon.nut +++ b/msu/systems/serialization/serialization_mod_addon.nut @@ -6,6 +6,10 @@ return savedVersion != "" && ::MSU.SemVer.compareVersionWithOperator(savedVersion, ">=", _version); } + function onValidateSavedMods( _modsInfo ) + { + } + function flagSerialize( _id, _object, _flags = null ) { ::MSU.System.Serialization.flagSerialize(this.Mod, _id, _object, _flags); diff --git a/msu/systems/serialization/serialization_system.nut b/msu/systems/serialization/serialization_system.nut index 04e1898d7..dbe25a333 100644 --- a/msu/systems/serialization/serialization_system.nut +++ b/msu/systems/serialization/serialization_system.nut @@ -20,6 +20,14 @@ _mod.Serialization = ::MSU.Class.SerializationModAddon(_mod); } + function onValidateSavedMods( _modsInfo ) + { + foreach (mod in this.Mods) + { + mod.Serialization.onValidateSavedMods(_modsInfo); + } + } + function flagSerialize( _mod, _id, _object, _flags = null ) { if (::MSU.isBBObject(_object)) diff --git a/ui/mods/msu/ui_hooks/main_menu_module.js b/ui/mods/msu/ui_hooks/main_menu_module.js index e6b6ebf3f..95e62dd14 100644 --- a/ui/mods/msu/ui_hooks/main_menu_module.js +++ b/ui/mods/msu/ui_hooks/main_menu_module.js @@ -38,3 +38,15 @@ MainMenuModule.prototype.createTacticalMapMenuButtons = function (_isRetreatAllo MSU.Hooks.MainMenuModule_createTacticalMapMenuButtons.call(this, _isRetreatAllowed, _isQuitAllowed, _quitText); this.addModOptionsButton(); }; + +MSU.Hooks.LoadCampaignMenuModule_addCampaignEntryToList = LoadCampaignMenuModule.prototype.addCampaignEntryToList; +LoadCampaignMenuModule.prototype.addCampaignEntryToList = function (_data) +{ + MSU.Hooks.LoadCampaignMenuModule_addCampaignEntryToList.call(this, _data); + if (_data.isIncompatibleVersion && _data.MSU_ModIncompatibility.length !== 0) + { + var entry = this.mListScrollContainer.find('.ui-control.campaign:last'); + console.error("binding tooltip to " + entry); + entry.bindTooltip({ contentType: "msu-generic", modId: MSU.ID, elementId: "LoadCampaign", errors: _data.MSU_ModIncompatibility}) + } +}; From 531f3f852fdb77ac6eb30786679aebc5af7cd994 Mon Sep 17 00:00:00 2001 From: LordMidas <55047920+LordMidas@users.noreply.github.com> Date: Fri, 17 Apr 2026 14:58:58 -0400 Subject: [PATCH 02/24] feat: clear targeted compatibility data instead of all of it --- msu/systems/serialization/saved_mods_info.nut | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/msu/systems/serialization/saved_mods_info.nut b/msu/systems/serialization/saved_mods_info.nut index f3d55fef2..53d38f244 100644 --- a/msu/systems/serialization/saved_mods_info.nut +++ b/msu/systems/serialization/saved_mods_info.nut @@ -91,9 +91,16 @@ // Use Case: You create a fork of a mod and want different compatibility data. // How to use: After clearing the compatibility data, you should define your own for that mod using // getMod(_id) and then using the Modern Hooks .require and .conflictWith functions on that mod. - function clearCompatibilityData( _id ) + function clearCompatibilityData( _sourceModID, _targetModID ) { - this.Mods[_id].CompatibilityData.clear(); + local sourceMod = this.getMod(_sourceModID); + for (local i = sourceMod.CompatibilityData.len() - 1; i >= 0; i--) + { + if (sourceMod.CompatibilityData[i].getModID() == _targetModID) + { + sourceMod.CompatibilityData.remove(i); + } + } } function validateMods() From cf96a88d98d6b0c6652e8f3ed13945d66ae6ff4f Mon Sep 17 00:00:00 2001 From: LordMidas <55047920+LordMidas@users.noreply.github.com> Date: Fri, 17 Apr 2026 22:11:34 -0400 Subject: [PATCH 03/24] refactor: save ids separately and save each mod's info as a new string This will hopefully avoid any pitfalls if metadata has a limit on the length of strings. Also makes it probably more readable when the data is printed out. --- msu/hooks/states/world_state.nut | 4 +- msu/systems/serialization/saved_mods_info.nut | 37 ++++++++++--------- 2 files changed, 22 insertions(+), 19 deletions(-) diff --git a/msu/hooks/states/world_state.nut b/msu/hooks/states/world_state.nut index 3a7fc8d4c..c558d9991 100644 --- a/msu/hooks/states/world_state.nut +++ b/msu/hooks/states/world_state.nut @@ -204,11 +204,11 @@ { __original(_in); - local modsInfo = _in.getMetaData().hasData(::MSU.Class.SavedModsInfo.MetaDataStringID) ? ::MSU.Class.SavedModsInfo(_in.getMetaData()) : null; + local modsInfo = _in.getMetaData().hasData(::MSU.Class.SavedModsInfo.MetaDataSavedIDsKey) ? ::MSU.Class.SavedModsInfo(_in.getMetaData()) : null; if (::MSU.Mod.Serialization.isSavedVersionAtLeast("1.1.0", _in.getMetaData())) { - local modIDs = modsInfo != null ? ::MSU.Table.keys(modInfos.getMods()) : split(_in.getMetaData().getString("MSU.SavedModIDs"), ","); + local modIDs = modsInfo != null ? split(_in.getMetaData().getString(::MSU.Class.SavedModsInfo.MetaDataSavedIDsKey, ::MSU.Class.SavedModsInfo.ModIDsSeparator)) : split(_in.getMetaData().getString("MSU.SavedModIDs"), ","); foreach (mod in ::Hooks.getMods()) { local IDIdx = modIDs.find(mod.getID()); diff --git a/msu/systems/serialization/saved_mods_info.nut b/msu/systems/serialization/saved_mods_info.nut index 53d38f244..35b693e45 100644 --- a/msu/systems/serialization/saved_mods_info.nut +++ b/msu/systems/serialization/saved_mods_info.nut @@ -1,10 +1,11 @@ ::MSU.Class.SavedModsInfo <- class { - static ModSeparator = "%%%%"; + static ModIDsSeparator = ","; static ModInfoSeparator = "&&"; static CompatInfoSeparator = "^^"; static CompatModSeparator = ","; - static MetaDataStringID = "MSU.SavedModsInfo"; + static MetaDataSavedIDsKey = "MSU.SavedModsInfoIDs"; + static MetaDataSavedInfoPrefix = "MSU.SavedModInfo"; // Used to pass the required _metadata arg in Hooks Mod constructor static EmptyTable = {}; @@ -18,16 +19,16 @@ if (_metadata == null) return; - local info = _metadata.hasData(this.MetaDataStringID) ? _metadata.getString(this.MetaDataStringID) : ""; - if (info == "") + local ids = _metadata.hasData(this.MetaDataSavedIDsKey) ? _metadata.getString(this.MetaDataSavedIDsKey) : ""; + if (ids == "") { this.__loadOldData(_metadata); return; } - foreach (mod in split(info, this.ModSeparator)) + foreach (id in split(ids, this.ModIDsSeparator)) { - local info = split(mod, this.ModInfoSeparator); + local info = split(_metadata.getString(this.MetaDataSavedInfoPrefix + id), this.ModInfoSeparator); local mod = ::Hooks.SQClass.Mod(info[0], info[2], info[1], this.EmptyTable); if (info[3] != "x") { @@ -197,10 +198,13 @@ function saveToMetaData( _metadata ) { - local info = ""; + local modIds = ""; foreach (mod in ::MSU.System.Serialization.Mods) { ::MSU.Mod.Debug.printLog(format("MSU Serialization: Saving %s (%s), Version: %s", mod.getName(), mod.getID(), mod.getVersionString())); + + modIds += mod.getID() + this.ModIDsSeparator; + local reqStr = ""; local conflictStr = ""; foreach (data in ::Hooks.getMod(mod.getID()).getCompatibilityData()) @@ -220,18 +224,17 @@ break; } } - info += format("%s%s%s%s%s%s%s%s%s%s", - mod.getID(), this.ModInfoSeparator, - mod.getName(), this.ModInfoSeparator, - mod.getVersionString(), this.ModInfoSeparator, - reqStr == "" ? "x" : reqStr.slice(0, -1), this.ModInfoSeparator, - conflictStr = "" ? "x" : conflictStr.slice(0, -this.CompatModSeparator.len()), - this.ModSeparator); + _metadata.setString(this.MetaDataSavedInfoPrefix + mod.getID(), format("%s%s%s%s%s%s%s%s%s", + mod.getID(), this.ModInfoSeparator, + mod.getName(), this.ModInfoSeparator, + mod.getVersionString(), this.ModInfoSeparator, + reqStr == "" ? "x" : reqStr.slice(0, -1), this.ModInfoSeparator, + conflictStr = "" ? "x" : conflictStr.slice(0, -this.CompatModSeparator.len()))); } - if (info != "") + if (modIds != "") { - info.slice(0, -this.ModSeparator.len()); + modIds.slice(0, -this.ModIDsSeparator.len()); } - _metadata.setString(this.MetaDataStringID, info); + _metadata.setString(this.MetaDataSavedIDsKey, modIds); } } From 7da4f36b6a703ed47525bb1144886a7aa08d25d4 Mon Sep 17 00:00:00 2001 From: LordMidas <55047920+LordMidas@users.noreply.github.com> Date: Fri, 17 Apr 2026 22:13:36 -0400 Subject: [PATCH 04/24] feat: save all mods registered with hooks instead of only msu registered --- msu/systems/serialization/saved_mods_info.nut | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/msu/systems/serialization/saved_mods_info.nut b/msu/systems/serialization/saved_mods_info.nut index 35b693e45..7377ff138 100644 --- a/msu/systems/serialization/saved_mods_info.nut +++ b/msu/systems/serialization/saved_mods_info.nut @@ -199,7 +199,7 @@ function saveToMetaData( _metadata ) { local modIds = ""; - foreach (mod in ::MSU.System.Serialization.Mods) + foreach (mod in ::Hooks.getMods()) { ::MSU.Mod.Debug.printLog(format("MSU Serialization: Saving %s (%s), Version: %s", mod.getName(), mod.getID(), mod.getVersionString())); From bdc9eecc74047caf8c20e4d3f0cfeb614b29d845 Mon Sep 17 00:00:00 2001 From: LordMidas <55047920+LordMidas@users.noreply.github.com> Date: Fri, 17 Apr 2026 22:14:02 -0400 Subject: [PATCH 05/24] fix: use member for slice and remove logging --- msu/systems/serialization/saved_mods_info.nut | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/msu/systems/serialization/saved_mods_info.nut b/msu/systems/serialization/saved_mods_info.nut index 7377ff138..06aa6db07 100644 --- a/msu/systems/serialization/saved_mods_info.nut +++ b/msu/systems/serialization/saved_mods_info.nut @@ -58,7 +58,6 @@ local ids = split(_metadata.getString("MSU.SavedModIDs"), ","); foreach (id in ids) { - ::logInfo("Loading old mod: " + id + " Version: " + _metadata.getString(id + "Version")); this.Mods[id] <- ::Hooks.SQClass.Mod(id, _metadata.getString(id + "Version") == "" ? "1.0.0" : _metadata.getString(id + "Version"), "", this.EmptyTable); } } @@ -228,7 +227,7 @@ mod.getID(), this.ModInfoSeparator, mod.getName(), this.ModInfoSeparator, mod.getVersionString(), this.ModInfoSeparator, - reqStr == "" ? "x" : reqStr.slice(0, -1), this.ModInfoSeparator, + reqStr == "" ? "x" : reqStr.slice(0, -this.CompatModSeparator.len()), this.ModInfoSeparator, conflictStr = "" ? "x" : conflictStr.slice(0, -this.CompatModSeparator.len()))); } if (modIds != "") From 74aca253df0c520babbc8c5db543a2fa53b891d3 Mon Sep 17 00:00:00 2001 From: LordMidas <55047920+LordMidas@users.noreply.github.com> Date: Fri, 17 Apr 2026 22:21:49 -0400 Subject: [PATCH 06/24] refactor: implement private functions to gen and read mod info strings --- msu/systems/serialization/saved_mods_info.nut | 98 ++++++++++--------- 1 file changed, 54 insertions(+), 44 deletions(-) diff --git a/msu/systems/serialization/saved_mods_info.nut b/msu/systems/serialization/saved_mods_info.nut index 06aa6db07..c44c5e9f4 100644 --- a/msu/systems/serialization/saved_mods_info.nut +++ b/msu/systems/serialization/saved_mods_info.nut @@ -28,24 +28,7 @@ foreach (id in split(ids, this.ModIDsSeparator)) { - local info = split(_metadata.getString(this.MetaDataSavedInfoPrefix + id), this.ModInfoSeparator); - local mod = ::Hooks.SQClass.Mod(info[0], info[2], info[1], this.EmptyTable); - if (info[3] != "x") - { - foreach (req in split(info[3], this.CompatModSeparator)) - { - mod.require(this.__getCompatString(req)); - } - } - if (info[4] != "x") - { - foreach (conflict in split(info[4], this.CompatModSeparator)) - { - mod.conflictWith(this.__getCompatString(conflict)); - } - } - - this.Mods[mod.getID()] <- mod; + this.Mods[mod.getID()] <- this.__getModFromInfoString(_metadata.getString(this.MetaDataSavedInfoPrefix + id)); } } @@ -70,6 +53,58 @@ return format("%s%s%s", info[0], operator == "x" ? "" : " " + operator + " ", version == "x" ? "" : " " + version + " "); } + function __getModInfoString( _mod ) + { + local reqStr = ""; + local conflictStr = ""; + foreach (data in ::Hooks.getMod(_mod.getID()).getCompatibilityData()) + { + local str = format("%s%s%s%s%s%s%s", + data.getModID(), this.CompatInfoSeparator, + data.getModName(), this.CompatInfoSeparator, + data.Operator == null ? "x" : data.Operator + "", this.CompatInfoSeparator, + data.Version == null ? "x" : data.Version + ""); + switch (data.CompatibilityType) + { + case ::Hooks.CompatibilityType.Requirement: + reqStr += str + this.CompatModSeparator; + break; + case ::Hooks.CompatibilityType.Incompatibility: + conflictStr += str + this.CompatModSeparator; + break; + } + } + return format("%s%s%s%s%s%s%s%s%s", + _mod.getID(), this.ModInfoSeparator, + _mod.getName(), this.ModInfoSeparator, + _mod.getVersionString(), this.ModInfoSeparator, + reqStr == "" ? "x" : reqStr.slice(0, -this.CompatModSeparator.len()), this.ModInfoSeparator, + conflictStr = "" ? "x" : conflictStr.slice(0, -this.CompatModSeparator.len())); + } + + function __getModFromInfoString( _str ) + { + local info = split(_str, this.ModInfoSeparator); + + local ret = ::Hooks.SQClass.Mod(info[0], info[2], info[1], this.EmptyTable); + if (info[3] != "x") + { + foreach (req in split(info[3], this.CompatModSeparator)) + { + ret.require(this.__getCompatString(req)); + } + } + if (info[4] != "x") + { + foreach (conflict in split(info[4], this.CompatModSeparator)) + { + ret.conflictWith(this.__getCompatString(conflict)); + } + } + + return ret; + } + function getMods() { return this.Mods; @@ -203,32 +238,7 @@ ::MSU.Mod.Debug.printLog(format("MSU Serialization: Saving %s (%s), Version: %s", mod.getName(), mod.getID(), mod.getVersionString())); modIds += mod.getID() + this.ModIDsSeparator; - - local reqStr = ""; - local conflictStr = ""; - foreach (data in ::Hooks.getMod(mod.getID()).getCompatibilityData()) - { - local str = format("%s%s%s%s%s%s%s", - data.getModID(), this.CompatInfoSeparator, - data.getModName(), this.CompatInfoSeparator, - data.Operator == null ? "x" : data.Operator + "", this.CompatInfoSeparator, - data.Version == null ? "x" : data.Version + ""); - switch (data.CompatibilityType) - { - case ::Hooks.CompatibilityType.Requirement: - reqStr += str + this.CompatModSeparator; - break; - case ::Hooks.CompatibilityType.Incompatibility: - conflictStr += str + this.CompatModSeparator; - break; - } - } - _metadata.setString(this.MetaDataSavedInfoPrefix + mod.getID(), format("%s%s%s%s%s%s%s%s%s", - mod.getID(), this.ModInfoSeparator, - mod.getName(), this.ModInfoSeparator, - mod.getVersionString(), this.ModInfoSeparator, - reqStr == "" ? "x" : reqStr.slice(0, -this.CompatModSeparator.len()), this.ModInfoSeparator, - conflictStr = "" ? "x" : conflictStr.slice(0, -this.CompatModSeparator.len()))); + _metadata.setString(this.MetaDataSavedInfoPrefix + mod.getID(), this.__getModInfoString(mod)); } if (modIds != "") { From d37ddbebc0205ad5e1e835187001ab38b8b208d0 Mon Sep 17 00:00:00 2001 From: LordMidas <55047920+LordMidas@users.noreply.github.com> Date: Fri, 17 Apr 2026 22:31:11 -0400 Subject: [PATCH 07/24] docs: add some comments --- msu/systems/serialization/saved_mods_info.nut | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/msu/systems/serialization/saved_mods_info.nut b/msu/systems/serialization/saved_mods_info.nut index c44c5e9f4..b9097298b 100644 --- a/msu/systems/serialization/saved_mods_info.nut +++ b/msu/systems/serialization/saved_mods_info.nut @@ -9,6 +9,9 @@ // Used to pass the required _metadata arg in Hooks Mod constructor static EmptyTable = {}; + // Table + // Key: ModID + // Value: Instance of ::Hooks.SQClass.Mod Mods = null; // Pass metadata to load saved mods info from that metadata. @@ -45,6 +48,12 @@ } } + // Converts a saved string to a new string which can + // be passed to a HooksMod.require or .conflictWith function. + // _str must be formatted as follows: + // id,name,operator,version + // where "commas" represent this.CompatInfoSeparator. + // Return example: "mod_msu >= 1.8.0" function __getCompatString( _str ) { local info = split(_str, this.CompatInfoSeparator); @@ -53,6 +62,9 @@ return format("%s%s%s", info[0], operator == "x" ? "" : " " + operator + " ", version == "x" ? "" : " " + version + " "); } + // Converts a mod to the following string: + // id,name,version,requirements,incompatibilities + // where "commas" represent this.CompatModSeparator. function __getModInfoString( _mod ) { local reqStr = ""; @@ -82,6 +94,10 @@ conflictStr = "" ? "x" : conflictStr.slice(0, -this.CompatModSeparator.len())); } + // Creates and returns a Hooks.SQClass.Mod instance from the info string. + // _str must be formatted as follows: + // id,name,version,requirements,incompatibilities + // where "commas" represent this.CompatModSeparator. function __getModFromInfoString( _str ) { local info = split(_str, this.ModInfoSeparator); @@ -144,6 +160,8 @@ ::MSU.System.Serialization.onValidateSavedMods(this); local compatErrors = []; + + // Check compatibility of saved mods with installed mods. foreach (mod in this.getMods()) { foreach (compatibilityData in mod.getCompatibilityData()) @@ -160,6 +178,7 @@ } } + // Check compatibility of installed mods with saved mods. foreach (mod in ::Hooks.getMods()) { foreach (compatibilityData in mod.getCompatibilityData()) From e5f4399732e1a699ba1286472474f2560c2d84bf Mon Sep 17 00:00:00 2001 From: LordMidas <55047920+LordMidas@users.noreply.github.com> Date: Fri, 17 Apr 2026 22:37:57 -0400 Subject: [PATCH 08/24] fix: include name in compat string --- msu/systems/serialization/saved_mods_info.nut | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/msu/systems/serialization/saved_mods_info.nut b/msu/systems/serialization/saved_mods_info.nut index b9097298b..5a7a46833 100644 --- a/msu/systems/serialization/saved_mods_info.nut +++ b/msu/systems/serialization/saved_mods_info.nut @@ -59,7 +59,8 @@ local info = split(_str, this.CompatInfoSeparator); local operator = info[2]; local version = info[3]; - return format("%s%s%s", info[0], operator == "x" ? "" : " " + operator + " ", version == "x" ? "" : " " + version + " "); + local name = info[1]; + return format("%s%s%s%s", info[0], operator == "x" ? "" : " " + operator + " ", version == "x" ? "" : " " + version + " ", name == "x" ? "" : " (" + name + ")"); } // Converts a mod to the following string: From d6ee7be5c32dfba967ae9d94ff9bcb2b3964e982 Mon Sep 17 00:00:00 2001 From: LordMidas <55047920+LordMidas@users.noreply.github.com> Date: Fri, 17 Apr 2026 22:44:56 -0400 Subject: [PATCH 09/24] fix: set mod name from hooks registration if not provided in compat data --- msu/systems/serialization/saved_mods_info.nut | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/msu/systems/serialization/saved_mods_info.nut b/msu/systems/serialization/saved_mods_info.nut index 5a7a46833..6e0e708cd 100644 --- a/msu/systems/serialization/saved_mods_info.nut +++ b/msu/systems/serialization/saved_mods_info.nut @@ -72,9 +72,14 @@ local conflictStr = ""; foreach (data in ::Hooks.getMod(_mod.getID()).getCompatibilityData()) { + local name = data.getModName(); + if (name == data.getModID() && ::Hooks.hasMod(data.getModID())) + { + name = ::Hooks.getMod(data.getModID()).getName(); + } local str = format("%s%s%s%s%s%s%s", data.getModID(), this.CompatInfoSeparator, - data.getModName(), this.CompatInfoSeparator, + name, this.CompatInfoSeparator, data.Operator == null ? "x" : data.Operator + "", this.CompatInfoSeparator, data.Version == null ? "x" : data.Version + ""); switch (data.CompatibilityType) From 6894951f138894da42c993ab9a9075043f5e80c1 Mon Sep 17 00:00:00 2001 From: LordMidas <55047920+LordMidas@users.noreply.github.com> Date: Fri, 17 Apr 2026 23:09:03 -0400 Subject: [PATCH 10/24] fix: bad comparison operator leading to conflictStr not being saved --- msu/systems/serialization/saved_mods_info.nut | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/msu/systems/serialization/saved_mods_info.nut b/msu/systems/serialization/saved_mods_info.nut index 6e0e708cd..b9460062b 100644 --- a/msu/systems/serialization/saved_mods_info.nut +++ b/msu/systems/serialization/saved_mods_info.nut @@ -97,7 +97,7 @@ _mod.getName(), this.ModInfoSeparator, _mod.getVersionString(), this.ModInfoSeparator, reqStr == "" ? "x" : reqStr.slice(0, -this.CompatModSeparator.len()), this.ModInfoSeparator, - conflictStr = "" ? "x" : conflictStr.slice(0, -this.CompatModSeparator.len())); + conflictStr == "" ? "x" : conflictStr.slice(0, -this.CompatModSeparator.len())); } // Creates and returns a Hooks.SQClass.Mod instance from the info string. From 87c2a3623cbc934839eaf005732a5eca1f102cf9 Mon Sep 17 00:00:00 2001 From: LordMidas <55047920+LordMidas@users.noreply.github.com> Date: Fri, 17 Apr 2026 23:10:03 -0400 Subject: [PATCH 11/24] refactor: rename function for consistency --- msu/systems/serialization/saved_mods_info.nut | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/msu/systems/serialization/saved_mods_info.nut b/msu/systems/serialization/saved_mods_info.nut index b9460062b..22b688708 100644 --- a/msu/systems/serialization/saved_mods_info.nut +++ b/msu/systems/serialization/saved_mods_info.nut @@ -66,7 +66,7 @@ // Converts a mod to the following string: // id,name,version,requirements,incompatibilities // where "commas" represent this.CompatModSeparator. - function __getModInfoString( _mod ) + function __getInfoStringFromMod( _mod ) { local reqStr = ""; local conflictStr = ""; @@ -263,7 +263,7 @@ ::MSU.Mod.Debug.printLog(format("MSU Serialization: Saving %s (%s), Version: %s", mod.getName(), mod.getID(), mod.getVersionString())); modIds += mod.getID() + this.ModIDsSeparator; - _metadata.setString(this.MetaDataSavedInfoPrefix + mod.getID(), this.__getModInfoString(mod)); + _metadata.setString(this.MetaDataSavedInfoPrefix + mod.getID(), this.__getInfoStringFromMod(mod)); } if (modIds != "") { From 5ede695100f269a90285ca2801e0254f02bf914a Mon Sep 17 00:00:00 2001 From: LordMidas <55047920+LordMidas@users.noreply.github.com> Date: Fri, 17 Apr 2026 23:29:06 -0400 Subject: [PATCH 12/24] fix: inverted ternary statement --- msu/hooks/states/world_state.nut | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/msu/hooks/states/world_state.nut b/msu/hooks/states/world_state.nut index c558d9991..a22250347 100644 --- a/msu/hooks/states/world_state.nut +++ b/msu/hooks/states/world_state.nut @@ -220,7 +220,7 @@ local oldVersion; if (modsInfo != null) { - oldVersion = modsInfo.hasMod(mod.getID()) ? "" : modInfo.getMod(mod.getID()).getVersionString(); + oldVersion = modsInfo.hasMod(mod.getID()) ? modInfo.getMod(mod.getID()).getVersionString() : ""; } else { From 6d013572c912493994501e7079bc3004285acb3b Mon Sep 17 00:00:00 2001 From: LordMidas <55047920+LordMidas@users.noreply.github.com> Date: Fri, 17 Apr 2026 23:29:23 -0400 Subject: [PATCH 13/24] fix: mistyped variable name --- msu/hooks/states/world_state.nut | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/msu/hooks/states/world_state.nut b/msu/hooks/states/world_state.nut index a22250347..60b6439c2 100644 --- a/msu/hooks/states/world_state.nut +++ b/msu/hooks/states/world_state.nut @@ -220,7 +220,7 @@ local oldVersion; if (modsInfo != null) { - oldVersion = modsInfo.hasMod(mod.getID()) ? modInfo.getMod(mod.getID()).getVersionString() : ""; + oldVersion = modsInfo.hasMod(mod.getID()) ? modsInfo.getMod(mod.getID()).getVersionString() : ""; } else { From a93e0cacd1fe1849905adef2f27c82a31b10b975 Mon Sep 17 00:00:00 2001 From: LordMidas <55047920+LordMidas@users.noreply.github.com> Date: Sat, 18 Apr 2026 17:24:05 -0400 Subject: [PATCH 14/24] fix: wrong access for id --- msu/systems/serialization/saved_mods_info.nut | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/msu/systems/serialization/saved_mods_info.nut b/msu/systems/serialization/saved_mods_info.nut index 22b688708..631ab1c39 100644 --- a/msu/systems/serialization/saved_mods_info.nut +++ b/msu/systems/serialization/saved_mods_info.nut @@ -31,7 +31,7 @@ foreach (id in split(ids, this.ModIDsSeparator)) { - this.Mods[mod.getID()] <- this.__getModFromInfoString(_metadata.getString(this.MetaDataSavedInfoPrefix + id)); + this.Mods[id] <- this.__getModFromInfoString(_metadata.getString(this.MetaDataSavedInfoPrefix + id)); } } From 4a82e033d55c3c28a5b70b847c77f7de6579e5ab Mon Sep 17 00:00:00 2001 From: LordMidas <55047920+LordMidas@users.noreply.github.com> Date: Sat, 18 Apr 2026 17:24:14 -0400 Subject: [PATCH 15/24] fix: update isSavedVersionAtLeast for new system --- msu/systems/serialization/serialization_mod_addon.nut | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/msu/systems/serialization/serialization_mod_addon.nut b/msu/systems/serialization/serialization_mod_addon.nut index 4948802bf..7a036269e 100644 --- a/msu/systems/serialization/serialization_mod_addon.nut +++ b/msu/systems/serialization/serialization_mod_addon.nut @@ -2,7 +2,13 @@ { function isSavedVersionAtLeast( _version, _metaData ) { - local savedVersion = _metaData.getString(this.Mod.getID() + "Version"); + local savedVersion = ""; + local info = ::MSU.Class.SavedModsInfo(_metaData); + if (info.hasMod(this.Mod.getID())) + { + savedVersion = info.getMod(this.Mod.getID()).getVersionString(); + } + return savedVersion != "" && ::MSU.SemVer.compareVersionWithOperator(savedVersion, ">=", _version); } From dd0dd30710ac9280c0e6ce9f747e73c9cad75656 Mon Sep 17 00:00:00 2001 From: LordMidas <55047920+LordMidas@users.noreply.github.com> Date: Sat, 18 Apr 2026 18:04:02 -0400 Subject: [PATCH 16/24] feat: add split function to array utils Different from native squirrel split which considers every character in the `_delimiter` string to be a delimiter rather than the entire `_delimiter` string as the delimiter. --- msu/utils/string.nut | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/msu/utils/string.nut b/msu/utils/string.nut index 895ddd6be..a94a1fcbe 100644 --- a/msu/utils/string.nut +++ b/msu/utils/string.nut @@ -34,4 +34,32 @@ { return _end.len() <= _string.len() && _string.slice(-_end.len()) == _end; } + + function split( _string, _delimiter, _skipEmpty = true ) + { + if (_string == "") + return []; + + if (_delimiter.len() == 1 && _skipEmpty) + return ::split(_string, _delimiter); + + local ret = []; + local idx; + while ((idx = _string.find(_delimiter)) != null) + { + local val = _string.slice(0, idx); + if (!_skipEmpty || val != "") + { + ret.push(val); + } + _string = _string.slice(idx + _delimiter.len()); + } + + if (!_skipEmpty || _string != "") + { + ret.push(_string); + } + + return ret; + } } From 14fbbe6b06b244320b47b68b95b4e55f5d38323f Mon Sep 17 00:00:00 2001 From: LordMidas <55047920+LordMidas@users.noreply.github.com> Date: Sat, 18 Apr 2026 22:17:50 -0400 Subject: [PATCH 17/24] fix: use MSU string split function instead of native squirrel split --- msu/systems/serialization/saved_mods_info.nut | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/msu/systems/serialization/saved_mods_info.nut b/msu/systems/serialization/saved_mods_info.nut index 631ab1c39..e0983944c 100644 --- a/msu/systems/serialization/saved_mods_info.nut +++ b/msu/systems/serialization/saved_mods_info.nut @@ -29,7 +29,7 @@ return; } - foreach (id in split(ids, this.ModIDsSeparator)) + foreach (id in ::MSU.String.split(ids, this.ModIDsSeparator)) { this.Mods[id] <- this.__getModFromInfoString(_metadata.getString(this.MetaDataSavedInfoPrefix + id)); } @@ -41,7 +41,7 @@ if (!_metadata.hasData("MSU.SavedModIDs")) return; - local ids = split(_metadata.getString("MSU.SavedModIDs"), ","); + local ids = ::MSU.String.split(_metadata.getString("MSU.SavedModIDs"), ","); foreach (id in ids) { this.Mods[id] <- ::Hooks.SQClass.Mod(id, _metadata.getString(id + "Version") == "" ? "1.0.0" : _metadata.getString(id + "Version"), "", this.EmptyTable); @@ -56,7 +56,7 @@ // Return example: "mod_msu >= 1.8.0" function __getCompatString( _str ) { - local info = split(_str, this.CompatInfoSeparator); + local info = ::MSU.String.split(_str, this.CompatInfoSeparator); local operator = info[2]; local version = info[3]; local name = info[1]; @@ -106,19 +106,19 @@ // where "commas" represent this.CompatModSeparator. function __getModFromInfoString( _str ) { - local info = split(_str, this.ModInfoSeparator); + local info = ::MSU.String.split(_str, this.ModInfoSeparator); local ret = ::Hooks.SQClass.Mod(info[0], info[2], info[1], this.EmptyTable); if (info[3] != "x") { - foreach (req in split(info[3], this.CompatModSeparator)) + foreach (req in ::MSU.String.split(info[3], this.CompatModSeparator)) { ret.require(this.__getCompatString(req)); } } if (info[4] != "x") { - foreach (conflict in split(info[4], this.CompatModSeparator)) + foreach (conflict in ::MSU.String.split(info[4], this.CompatModSeparator)) { ret.conflictWith(this.__getCompatString(conflict)); } From 14055dc2a45e6d6c0a41159ee6af72cdf447f65e Mon Sep 17 00:00:00 2001 From: LordMidas <55047920+LordMidas@users.noreply.github.com> Date: Sat, 18 Apr 2026 22:18:47 -0400 Subject: [PATCH 18/24] fix: convert operator from integer to string --- msu/systems/serialization/saved_mods_info.nut | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/msu/systems/serialization/saved_mods_info.nut b/msu/systems/serialization/saved_mods_info.nut index e0983944c..3e3938430 100644 --- a/msu/systems/serialization/saved_mods_info.nut +++ b/msu/systems/serialization/saved_mods_info.nut @@ -60,7 +60,7 @@ local operator = info[2]; local version = info[3]; local name = info[1]; - return format("%s%s%s%s", info[0], operator == "x" ? "" : " " + operator + " ", version == "x" ? "" : " " + version + " ", name == "x" ? "" : " (" + name + ")"); + return format("%s%s%s%s", info[0], operator == "x" ? "" : " " + this.__parseOperatorToString(operator.tointeger()) + " ", version == "x" ? "" : version, name == "x" ? "" : " (" + name + ")"); } // Converts a mod to the following string: From 6c20e300b225a9d4c9800bfc4960ab1b0b6d5ed4 Mon Sep 17 00:00:00 2001 From: LordMidas <55047920+LordMidas@users.noreply.github.com> Date: Sat, 18 Apr 2026 22:19:06 -0400 Subject: [PATCH 19/24] fix: remove parantheses from mod name otherwise modern hooks can't match --- msu/systems/serialization/saved_mods_info.nut | 2 ++ 1 file changed, 2 insertions(+) diff --git a/msu/systems/serialization/saved_mods_info.nut b/msu/systems/serialization/saved_mods_info.nut index 3e3938430..c9b71a747 100644 --- a/msu/systems/serialization/saved_mods_info.nut +++ b/msu/systems/serialization/saved_mods_info.nut @@ -77,6 +77,8 @@ { name = ::Hooks.getMod(data.getModID()).getName(); } + name = ::String.replace(name, "(", ""); + name = ::String.replace(name, ")", ""); local str = format("%s%s%s%s%s%s%s", data.getModID(), this.CompatInfoSeparator, name, this.CompatInfoSeparator, From 8a51f463382b689282b2167fb3a7fd033f778b35 Mon Sep 17 00:00:00 2001 From: LordMidas <55047920+LordMidas@users.noreply.github.com> Date: Sat, 18 Apr 2026 22:20:48 -0400 Subject: [PATCH 20/24] feat: only look for conflictWith when validating installed vs saved Because for required mods you already have them installed otherwise modern hooks won't let you get to main menu without error. We should not validate requirements for installed mods in savegames because it will make it impossible to continue your older save if a mod updates a dependency requirement e.g. requires MSU 1.8.0 will make it impossible to load saves made with MSU 1.7.0. If you really don't want an older save to be loadable you should declare conflict with that version. --- msu/systems/serialization/saved_mods_info.nut | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/msu/systems/serialization/saved_mods_info.nut b/msu/systems/serialization/saved_mods_info.nut index c9b71a747..6b033b6b1 100644 --- a/msu/systems/serialization/saved_mods_info.nut +++ b/msu/systems/serialization/saved_mods_info.nut @@ -187,10 +187,16 @@ } // Check compatibility of installed mods with saved mods. + // For this we only look at CompatibilityType.Incompatibility + // because for "required" mods you won't even be able to load + // into main menu if you are missing required mods. foreach (mod in ::Hooks.getMods()) { foreach (compatibilityData in mod.getCompatibilityData()) { + if (compatibilityData.CompatibilityType != ::Hooks.CompatibilityType.Incompatibility) + continue; + local result = compatibilityData.validate(this.getMods()); if (result == ::Hooks.CompatibilityCheckResult.Success) continue; From 4d50ed47999dbf96d42cbb70c6fce0515b5e1cfd Mon Sep 17 00:00:00 2001 From: LordMidas <55047920+LordMidas@users.noreply.github.com> Date: Sun, 19 Apr 2026 02:07:06 -0400 Subject: [PATCH 21/24] fix: misplaced paranthesis --- msu/hooks/states/world_state.nut | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/msu/hooks/states/world_state.nut b/msu/hooks/states/world_state.nut index 60b6439c2..d3440aee5 100644 --- a/msu/hooks/states/world_state.nut +++ b/msu/hooks/states/world_state.nut @@ -208,7 +208,7 @@ if (::MSU.Mod.Serialization.isSavedVersionAtLeast("1.1.0", _in.getMetaData())) { - local modIDs = modsInfo != null ? split(_in.getMetaData().getString(::MSU.Class.SavedModsInfo.MetaDataSavedIDsKey, ::MSU.Class.SavedModsInfo.ModIDsSeparator)) : split(_in.getMetaData().getString("MSU.SavedModIDs"), ","); + local modIDs = modsInfo != null ? split(_in.getMetaData().getString(::MSU.Class.SavedModsInfo.MetaDataSavedIDsKey), ::MSU.Class.SavedModsInfo.ModIDsSeparator) : split(_in.getMetaData().getString("MSU.SavedModIDs"), ","); foreach (mod in ::Hooks.getMods()) { local IDIdx = modIDs.find(mod.getID()); From dd2ec03fd7ac2444c1b4dc895ca026d9e6f0e96e Mon Sep 17 00:00:00 2001 From: LordMidas <55047920+LordMidas@users.noreply.github.com> Date: Sun, 26 Apr 2026 00:12:13 -0400 Subject: [PATCH 22/24] refactor: move load campaign menu module hook to separate js file --- ui/mods/msu/ui_hooks/load_campaign_menu_module.js | 11 +++++++++++ ui/mods/msu/ui_hooks/main_menu_module.js | 12 ------------ 2 files changed, 11 insertions(+), 12 deletions(-) create mode 100644 ui/mods/msu/ui_hooks/load_campaign_menu_module.js diff --git a/ui/mods/msu/ui_hooks/load_campaign_menu_module.js b/ui/mods/msu/ui_hooks/load_campaign_menu_module.js new file mode 100644 index 000000000..bd56e0b50 --- /dev/null +++ b/ui/mods/msu/ui_hooks/load_campaign_menu_module.js @@ -0,0 +1,11 @@ +MSU.Hooks.LoadCampaignMenuModule_addCampaignEntryToList = LoadCampaignMenuModule.prototype.addCampaignEntryToList; +LoadCampaignMenuModule.prototype.addCampaignEntryToList = function (_data) +{ + MSU.Hooks.LoadCampaignMenuModule_addCampaignEntryToList.call(this, _data); + if (_data.isIncompatibleVersion && _data.MSU_ModIncompatibility.length !== 0) + { + var entry = this.mListScrollContainer.find('.ui-control.campaign:last'); + console.error("binding tooltip to " + entry); + entry.bindTooltip({ contentType: "msu-generic", modId: MSU.ID, elementId: "LoadCampaign", errors: _data.MSU_ModIncompatibility}) + } +}; diff --git a/ui/mods/msu/ui_hooks/main_menu_module.js b/ui/mods/msu/ui_hooks/main_menu_module.js index 95e62dd14..e6b6ebf3f 100644 --- a/ui/mods/msu/ui_hooks/main_menu_module.js +++ b/ui/mods/msu/ui_hooks/main_menu_module.js @@ -38,15 +38,3 @@ MainMenuModule.prototype.createTacticalMapMenuButtons = function (_isRetreatAllo MSU.Hooks.MainMenuModule_createTacticalMapMenuButtons.call(this, _isRetreatAllowed, _isQuitAllowed, _quitText); this.addModOptionsButton(); }; - -MSU.Hooks.LoadCampaignMenuModule_addCampaignEntryToList = LoadCampaignMenuModule.prototype.addCampaignEntryToList; -LoadCampaignMenuModule.prototype.addCampaignEntryToList = function (_data) -{ - MSU.Hooks.LoadCampaignMenuModule_addCampaignEntryToList.call(this, _data); - if (_data.isIncompatibleVersion && _data.MSU_ModIncompatibility.length !== 0) - { - var entry = this.mListScrollContainer.find('.ui-control.campaign:last'); - console.error("binding tooltip to " + entry); - entry.bindTooltip({ contentType: "msu-generic", modId: MSU.ID, elementId: "LoadCampaign", errors: _data.MSU_ModIncompatibility}) - } -}; From d3b146d75407a7644e330a4771d8e7a7bf6e6c76 Mon Sep 17 00:00:00 2001 From: LordMidas <55047920+LordMidas@users.noreply.github.com> Date: Sun, 26 Apr 2026 00:16:32 -0400 Subject: [PATCH 23/24] feat: add Mod Conflict to compaign entry message --- ui/mods/msu/ui_hooks/load_campaign_menu_module.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ui/mods/msu/ui_hooks/load_campaign_menu_module.js b/ui/mods/msu/ui_hooks/load_campaign_menu_module.js index bd56e0b50..4a039a79f 100644 --- a/ui/mods/msu/ui_hooks/load_campaign_menu_module.js +++ b/ui/mods/msu/ui_hooks/load_campaign_menu_module.js @@ -6,6 +6,7 @@ LoadCampaignMenuModule.prototype.addCampaignEntryToList = function (_data) { var entry = this.mListScrollContainer.find('.ui-control.campaign:last'); console.error("binding tooltip to " + entry); - entry.bindTooltip({ contentType: "msu-generic", modId: MSU.ID, elementId: "LoadCampaign", errors: _data.MSU_ModIncompatibility}) + entry.bindTooltip({ contentType: "msu-generic", modId: MSU.ID, elementId: "LoadCampaign", errors: _data.MSU_ModIncompatibility }) + entry.assignListCampaignDayName("- Incompatible Version / DLC Missing / Mod Conflict -"); } }; From 7335475b102bf13730df8ad95ed17aa5e7c2ef21 Mon Sep 17 00:00:00 2001 From: LordMidas <55047920+LordMidas@users.noreply.github.com> Date: Sun, 26 Apr 2026 00:20:09 -0400 Subject: [PATCH 24/24] feat: allow requiring your own mod in saved games --- msu/systems/serialization/saved_mods_info.nut | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/msu/systems/serialization/saved_mods_info.nut b/msu/systems/serialization/saved_mods_info.nut index 6b033b6b1..abbf77dfd 100644 --- a/msu/systems/serialization/saved_mods_info.nut +++ b/msu/systems/serialization/saved_mods_info.nut @@ -194,7 +194,9 @@ { foreach (compatibilityData in mod.getCompatibilityData()) { - if (compatibilityData.CompatibilityType != ::Hooks.CompatibilityType.Incompatibility) + // We allow "requiring" your own mod in save games. This is a method for + // modders to declare mods that are unsafe to add to existing runs. + if (compatibilityData.CompatibilityType != ::Hooks.CompatibilityType.Incompatibility && compatibilityData.getModID() != mod.getID()) continue; local result = compatibilityData.validate(this.getMods());