From 71427ba3ffa3860ca4067cb030197c1c98762fcc 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 df21c38139fa0347f86eeaf2dfa36186cf0d2762 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 a36fdc20ea8c1b9c82ad110f1690cef6a79dd0a3 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 7f3ce88315f35679a4e7194d657f8a257d37eb9b 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 4367fc6916f715f623e6f368864f1109a5e1c121 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 8a3bc3021203c875f560fb8cf85fafa5f7e281dd 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 0ed4ca84659f99377d27db8968a3cded430313ff 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 f079713b5cd688b349c428a0b2ee198d422d990d 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 53867e1060d0a8c1dd971d7bb900e102f91387ca 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 1c3c57c2fa5271469f33f2ed5e2ed1eadf1d0215 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 51c19e7cea021af92b2960abab1dbdaec25e9ddf 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 3911f150bac04b55d5fc5c0fe54fa3733d48553e 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 e7219644047c9e575f4de4d9ea36fb1e74633d86 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 2e696d90a3800d306d4af160ba29efd115f7ab7e 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 9705d97cafef8227d03779b999a705ae31944e3a 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 d068751da923f5b4b413f8932518a8b8df3671cb 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 b7020695586e06d9dfc7b7cfa4fca08300d7f360 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 1734782559f26fc72e0e095056bc8eff333fc0fe 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 f43720bd04f3390f1e869a795a188d66c184b687 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 af77ae59534b3cd81e4c09bb8942f3d21b0d1122 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 6abf602995782cac0ba4f5e94ca080b353ed58c5 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 c3a77b25470c7354b0072b7ff9b10daddff77701 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 ad860db74913e73ba50eb7790f0b5e19dbdacaab 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 cc5e31dda766372f224a7bacd35117fcab38c96f 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());