Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
74cf356
feat: validate mods compatibility when trying to load saved games
LordMidas Apr 17, 2026
531f3f8
feat: clear targeted compatibility data instead of all of it
LordMidas Apr 17, 2026
cf96a88
refactor: save ids separately and save each mod's info as a new string
LordMidas Apr 18, 2026
7da4f36
feat: save all mods registered with hooks instead of only msu registered
LordMidas Apr 18, 2026
bdc9eec
fix: use member for slice and remove logging
LordMidas Apr 18, 2026
74aca25
refactor: implement private functions to gen and read mod info strings
LordMidas Apr 18, 2026
d37ddbe
docs: add some comments
LordMidas Apr 18, 2026
e5f4399
fix: include name in compat string
LordMidas Apr 18, 2026
d6ee7be
fix: set mod name from hooks registration if not provided in compat data
LordMidas Apr 18, 2026
6894951
fix: bad comparison operator leading to conflictStr not being saved
LordMidas Apr 18, 2026
87c2a36
refactor: rename function for consistency
LordMidas Apr 18, 2026
5ede695
fix: inverted ternary statement
LordMidas Apr 18, 2026
6d01357
fix: mistyped variable name
LordMidas Apr 18, 2026
a93e0ca
fix: wrong access for id
LordMidas Apr 18, 2026
4a82e03
fix: update isSavedVersionAtLeast for new system
LordMidas Apr 18, 2026
dd0dd30
feat: add split function to array utils
LordMidas Apr 18, 2026
14fbbe6
fix: use MSU string split function instead of native squirrel split
LordMidas Apr 19, 2026
14055dc
fix: convert operator from integer to string
LordMidas Apr 19, 2026
6c20e30
fix: remove parantheses from mod name otherwise modern hooks can't match
LordMidas Apr 19, 2026
8a51f46
feat: only look for conflictWith when validating installed vs saved
LordMidas Apr 19, 2026
4d50ed4
fix: misplaced paranthesis
LordMidas Apr 19, 2026
dd2ec03
refactor: move load campaign menu module hook to separate js file
LordMidas Apr 26, 2026
d3b146d
feat: add Mod Conflict to compaign entry message
LordMidas Apr 26, 2026
7335475
feat: allow requiring your own mod in saved games
LordMidas Apr 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 15 additions & 13 deletions msu/hooks/states/world_state.nut
Original file line number Diff line number Diff line change
Expand Up @@ -197,34 +197,36 @@
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.MetaDataSavedIDsKey) ? ::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 ? 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());
if (IDIdx != null)
{
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()) ? modsInfo.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()));
Expand Down
15 changes: 15 additions & 0 deletions msu/hooks/ui/global/data_helper.nut
Original file line number Diff line number Diff line change
Expand Up @@ -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 ;
});

25 changes: 25 additions & 0 deletions msu/msu_mod/msu_tooltips.nut
Original file line number Diff line number Diff line change
@@ -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."),
Expand Down
284 changes: 284 additions & 0 deletions msu/systems/serialization/saved_mods_info.nut
Original file line number Diff line number Diff line change
@@ -0,0 +1,284 @@
::MSU.Class.SavedModsInfo <- class
{
static ModIDsSeparator = ",";
static ModInfoSeparator = "&&";
static CompatInfoSeparator = "^^";
static CompatModSeparator = ",";
static MetaDataSavedIDsKey = "MSU.SavedModsInfoIDs";
static MetaDataSavedInfoPrefix = "MSU.SavedModInfo";
// 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.
constructor( _metadata = null )
{
this.Mods = {};

if (_metadata == null)
return;

local ids = _metadata.hasData(this.MetaDataSavedIDsKey) ? _metadata.getString(this.MetaDataSavedIDsKey) : "";
if (ids == "")
{
this.__loadOldData(_metadata);
return;
}

foreach (id in ::MSU.String.split(ids, this.ModIDsSeparator))
{
this.Mods[id] <- this.__getModFromInfoString(_metadata.getString(this.MetaDataSavedInfoPrefix + id));
}
}

// Used to load saved mod data from MSU 1.8.0 and older.
function __loadOldData( _metadata )
{
if (!_metadata.hasData("MSU.SavedModIDs"))
return;

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);
}
}

// 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 = ::MSU.String.split(_str, this.CompatInfoSeparator);
local operator = info[2];
local version = info[3];
local name = info[1];
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:
// id,name,version,requirements,incompatibilities
// where "commas" represent this.CompatModSeparator.
function __getInfoStringFromMod( _mod )
{
local reqStr = "";
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();
}
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,
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()));
}

// 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 = ::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 ::MSU.String.split(info[3], this.CompatModSeparator))
{
ret.require(this.__getCompatString(req));
}
}
if (info[4] != "x")
{
foreach (conflict in ::MSU.String.split(info[4], this.CompatModSeparator))
{
ret.conflictWith(this.__getCompatString(conflict));
}
}

return ret;
}

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( _sourceModID, _targetModID )
{
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()
{
// We allow mods to adjust the compatibility data before we validate it.
::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())
{
local result = compatibilityData.validate(::Hooks.getMods());
if (result == ::Hooks.CompatibilityCheckResult.Success)
continue;
compatErrors.push({
Source = mod,
Target = compatibilityData,
Reason = result,
IsTargetSaved = false
});
}
}

// 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())
{
// 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());
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 modIds = "";
foreach (mod in ::Hooks.getMods())
{
::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.__getInfoStringFromMod(mod));
}
if (modIds != "")
{
modIds.slice(0, -this.ModIDsSeparator.len());
}
_metadata.setString(this.MetaDataSavedIDsKey, modIds);
}
}
Loading