Pre spt mod loader (#102)

* partial commit

* pre spt mod loader refactored

---------

Co-authored-by: Alex <alex@dm-me-for-questions.com>
Co-authored-by: clodan <clodan@clodan.com>
This commit is contained in:
clodanSPT
2025-02-25 16:34:51 +00:00
committed by GitHub
parent b7e734c781
commit 2464246fd7
11 changed files with 718 additions and 49 deletions
+13
View File
@@ -0,0 +1,13 @@
using System.Text.Json.Serialization;
namespace Core.Models.Spt.Mod;
public class ModOrder
{
[JsonPropertyName("order")]
public List<string> Order
{
get;
set;
}
}
@@ -1,73 +1,87 @@
namespace Core.Models.Spt.Mod;
using System.Text.Json.Serialization;
namespace Core.Models.Spt.Mod;
public record PackageJsonData
{
[JsonPropertyName("name")]
public string? Name
{
get;
set;
}
[JsonPropertyName("author")]
public string? Author
{
get;
set;
}
[JsonPropertyName("contributors")]
public List<string>? Contributors
{
get;
set;
}
[JsonPropertyName("version")]
public string? Version
{
get;
set;
}
[JsonPropertyName("sptVersion")]
public string? SptVersion
{
get;
set;
}
[JsonPropertyName("loadBefore")]
public List<string>? LoadBefore
{
get;
set;
}
[JsonPropertyName("loadAfter")]
public List<string>? LoadAfter
{
get;
set;
}
public List<string>? IncompatibleMods
[JsonPropertyName("incompatibilities")]
public List<string>? Incompatibilities
{
get;
set;
}
public Dictionary<string, string>? Dependencies
[JsonPropertyName("modDependencies")]
public Dictionary<string, string>? ModDependencies
{
get;
set;
}
[JsonPropertyName("url")]
public string? Url
{
get;
set;
}
[JsonPropertyName("isBundleMod")]
public bool? IsBundleMod
{
get;
set;
}
[JsonPropertyName("licence")]
public string? Licence
{
get;
+3 -3
View File
@@ -9,13 +9,13 @@ public class FileUtil(
{
protected const string _modBasePath = "user/mods/";
public List<string> GetFiles(string path, bool recursive = false)
public List<string> GetFiles(string path, bool recursive = false, string searchPattern = "*")
{
var files = new List<string>(Directory.GetFiles(path));
var files = new List<string>(Directory.GetFiles(path, searchPattern));
if (recursive)
{
files.AddRange(Directory.GetDirectories(path).SelectMany(d => GetFiles(d, recursive)));
files.AddRange(Directory.GetDirectories(path).SelectMany(d => GetFiles(d, recursive, searchPattern)));
}
return files;
+13
View File
@@ -0,0 +1,13 @@
namespace SptCommon.Semver;
public interface ISemVer
{
string MaxSatisfying(List<string> versions);
string MaxSatisfying(IEnumerable<string> versions);
string MaxSatisfying(string version, List<string> versions);
string MaxSatisfying(string version, IEnumerable<string> versions);
bool Satisfies(string version, string testVersion);
bool AnySatisfies(string version, List<string> testVersions);
bool IsValid(string version);
bool IsValidRange(string version);
}
@@ -0,0 +1,47 @@
using Range = SemanticVersioning.Range;
using Version = SemanticVersioning.Version;
namespace SptCommon.Semver.Implementations;
public class SemanticVersioningSemVer : ISemVer
{
public string MaxSatisfying(List<string> versions)
{
return MaxSatisfying(versions.AsEnumerable());
}
public string MaxSatisfying(IEnumerable<string> versions)
{
return MaxSatisfying("*", versions);
}
public string MaxSatisfying(string version, List<string> versions)
{
return MaxSatisfying(version, versions.AsEnumerable());
}
public string MaxSatisfying(string version, IEnumerable<string> versions)
{
return Range.MaxSatisfying(version, versions, true);
}
public bool Satisfies(string version, string testVersion)
{
return Range.IsSatisfied(version, testVersion, true);
}
public bool AnySatisfies(string version, List<string> testVersions)
{
return testVersions.Any(v => Satisfies(version, v));
}
public bool IsValid(string version)
{
return Version.TryParse(version, out _);
}
public bool IsValidRange(string version)
{
return Range.TryParse(version, out _);
}
}
+4
View File
@@ -7,4 +7,8 @@
<OutputType>Library</OutputType>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="SemanticVersioning" Version="3.0.0" />
</ItemGroup>
</Project>
@@ -1,7 +1,7 @@
using System.Reflection;
using HarmonyLib;
namespace Server;
namespace Server.Modding;
public class HarmonyBootstrapper
{
@@ -2,7 +2,7 @@ using System.Reflection;
using System.Text.Json;
using Core.Models.Spt.Mod;
namespace Server;
namespace Server.Modding;
public class ModDllLoader
{
@@ -38,41 +38,9 @@ public class ModDllLoader
}
}
ValidateModDependencies(mods);
// Sort by mods LoadBefore/LoadAfter collections
SortMods(mods);
return mods;
}
/// <summary>
/// Ensure all mods have their dependencies
/// </summary>
/// <param name="mods">Mods to check dependencies of</param>
private static void ValidateModDependencies(List<SptMod> mods)
{
foreach (var sptMod in mods)
{
if (sptMod.PackageJson?.Dependencies?.Count > 0)
{
// Has dependencies, validate they exist
foreach (var dependency in sptMod.PackageJson.Dependencies
.Where(dependency => !mods.Exists(x => string.Equals(x.PackageJson?.Name, dependency.Key, StringComparison.OrdinalIgnoreCase))))
{
// TODO: also check version passes semver check
throw new Exception($"Mod: {sptMod.PackageJson.Name} is unable to load as it cannot find another mod it needs: {dependency.Key} version: {dependency.Value}");
}
}
}
}
private static void SortMods(List<SptMod> mods)
{
// TODO: implement
Console.WriteLine($"NOT IMPLEMENTED: SortMods");
}
/// <summary>
/// Check the provided directory path for a dll and .json file, load into memory
/// </summary>
@@ -125,6 +93,13 @@ public class ModDllLoader
throw new Exception($"No Assemblies found in path: {Path.GetFullPath(path)}");
}
if (result.PackageJson?.Name == null || result.PackageJson?.Author == null ||
result.PackageJson?.Version == null || result.PackageJson?.Licence == null ||
result.PackageJson?.SptVersion == null)
{
throw new Exception($"The package.json file for {path} is missing one of these properties: name, author, licence, version or sptVersion");
}
if (result.Assembly is not null && result.PackageJson is not null)
{
Console.WriteLine($"Loaded: {result.PackageJson.Name} Version: {result.PackageJson.Version} by: {result.PackageJson.Author}");
+152
View File
@@ -0,0 +1,152 @@
using Core.Models.Spt.Mod;
using Core.Utils.Cloners;
namespace Server.Modding;
public class ModLoadOrder(ICloner cloner)
{
protected Dictionary<string, PackageJsonData> mods = new();
protected Dictionary<string, PackageJsonData> modsAvailable = new();
protected HashSet<string> loadOrder = new();
public void SetModList(Dictionary<string, PackageJsonData> mods)
{
this.mods = mods;
modsAvailable = cloner.Clone(this.mods);
loadOrder = [];
var visited = new HashSet<string>();
// invert loadBefore into loadAfter on specified mods
foreach (var (modName, modConfig) in modsAvailable)
{
if ((modConfig.LoadBefore ?? []).Count > 0)
{
InvertLoadBefore(modName);
}
}
foreach (var modName in modsAvailable.Keys)
{
GetLoadOrderRecursive(modName, visited);
}
}
public List<string> GetLoadOrder()
{
return [..loadOrder];
}
public HashSet<string> GetModsOnLoadBefore(string mod)
{
if (!mods.ContainsKey(mod))
{
throw new Exception($"The mod {mod} does not exist!");
}
var config = mods[mod];
var loadBefore = new HashSet<string>(config.LoadBefore);
foreach (var loadBeforeMod in loadBefore)
{
if (!mods.ContainsKey(loadBeforeMod))
{
loadBefore.Remove(loadBeforeMod);
}
}
return loadBefore;
}
/**
* TODO: Is this not needed at all?
*/
public HashSet<string> GetModsOnLoadAfter(string mod)
{
if (!mods.ContainsKey(mod))
{
throw new Exception($"The mod {mod} does not exist!");
}
var config = mods[mod];
var loadAfter = new HashSet<string>(config.LoadAfter);
foreach (var loadAfterMod in loadAfter)
{
if (!mods.ContainsKey(loadAfterMod))
{
loadAfter.Remove(loadAfterMod);
}
}
return loadAfter;
}
protected void InvertLoadBefore(string mod)
{
var loadBefore = GetModsOnLoadBefore(mod);
foreach (var loadBeforeMod in loadBefore)
{
var loadBeforeModConfig = modsAvailable[loadBeforeMod];
loadBeforeModConfig.LoadAfter ??= [];
loadBeforeModConfig.LoadAfter.Add(mod);
modsAvailable.Add(loadBeforeMod, loadBeforeModConfig);
}
}
protected void GetLoadOrderRecursive(string mod, HashSet<string> visited)
{
// Validate package
if (loadOrder.Contains(mod))
{
return;
}
if (visited.Contains(mod))
{
// Additional info to help debug
throw new Exception($"Cyclic dependency detected for mod {mod}!");
}
// Check dependencies
if (!modsAvailable.ContainsKey(mod))
{
throw new Exception("modloader-error_parsing_mod_load_order");
}
var config = modsAvailable[mod];
config.LoadAfter ??= [];
config.ModDependencies ??= [];
var dependencies = new HashSet<string>(config.ModDependencies.Keys);
foreach (var modAfter in config.LoadAfter)
{
if (modsAvailable.ContainsKey(modAfter))
{
if (modsAvailable[modAfter]?.LoadAfter?.Contains(mod) ?? false)
{
throw new Exception("modloader-load_order_conflict");
}
dependencies.Add(modAfter);
}
}
visited.Add(mod);
foreach (var nextMod in dependencies)
{
GetLoadOrderRecursive(nextMod, visited);
}
visited.Remove(mod);
loadOrder.Add(mod);
}
}
+415
View File
@@ -0,0 +1,415 @@
using Core.Models.Spt.Config;
using Core.Models.Spt.Mod;
using Core.Models.Utils;
using Core.Servers;
using Core.Services;
using Core.Utils;
using SptCommon.Semver;
using LogLevel = Core.Models.Spt.Logging.LogLevel;
namespace Server.Modding;
public class ModValidator(
ISptLogger<ModValidator> logger,
LocalisationService localisationService,
ConfigServer configServer,
ISemVer semVer,
ModLoadOrder modLoadOrder,
JsonUtil jsonUtil,
FileUtil fileUtil)
{
protected readonly string basepath = "user/mods/";
protected readonly string modOrderPath = "user/mods/order.json";
protected Dictionary<string, int> order = [];
protected Dictionary<string, SptMod> imported = [];
protected HashSet<string> skippedMods = [];
protected CoreConfig sptConfig = configServer.GetConfig<CoreConfig>();
public List<SptMod> ValidateAndSort(List<SptMod> mods)
{
if (ProgramStatics.MODS())
{
ValidateMods(mods);
modLoadOrder.SetModList(imported.ToDictionary(m => m.Key, m => m.Value.PackageJson));
var finalList = new List<SptMod>();
foreach (var orderMod in SortModsLoadOrder())
{
if (!imported.TryGetValue(orderMod, out var loadedMod))
{
throw new Exception($"Unable to find mod {orderMod} in loaded mods");
}
finalList.Add(loadedMod);
}
return finalList;
}
return [];
}
public string GetModPath(string mod)
{
return $"{basepath}{mod}/";
}
protected void ValidateMods(List<SptMod> mods)
{
logger.Info(localisationService.GetText("modloader-loading_mods", mods.Count));
// Mod order
if (!fileUtil.FileExists(modOrderPath))
{
logger.Info(localisationService.GetText("modloader-mod_order_missing"));
// Write file with empty order array to disk
fileUtil.WriteFile(modOrderPath, jsonUtil.Serialize(new ModOrder
{
Order = []
}));
}
else
{
var modOrder = File.ReadAllText(modOrderPath);
try
{
var modOrderArray = jsonUtil.Deserialize<ModOrder>(modOrder).Order;
for (var i = 0; i < modOrderArray.Count; i++)
{
order.Add(modOrderArray[i], i);
}
}
catch (Exception e)
{
logger.Error(localisationService.GetText("modloader-mod_order_error"), e);
}
}
// Validate and remove broken mods from mod list
var validMods = GetValidMods(mods);
var modPackageData = validMods.ToDictionary(m => m.PackageJson!.Name!, m => m.PackageJson!);
CheckForDuplicateMods(modPackageData);
// Used to check all errors before stopping the load execution
var errorsFound = false;
foreach (var modToValidate in modPackageData.Values)
{
if (ShouldSkipMod(modToValidate))
{
// skip error checking and dependency install for mods already marked as skipped.
continue;
}
// Returns if any mod dependency is not satisfied
if (!AreModDependenciesFulfilled(modToValidate, modPackageData))
{
errorsFound = true;
}
// Returns if at least two incompatible mods are found
if (!IsModCompatible(modToValidate, modPackageData))
{
errorsFound = true;
}
// Returns if mod isnt compatible with this verison of spt
if (!IsModCombatibleWithSpt(modToValidate))
{
errorsFound = true;
}
}
if (errorsFound)
{
logger.Error(localisationService.GetText("modloader-no_mods_loaded"));
return;
}
// sort mod order
var missingFromOrderJSON = new Dictionary<string, bool>();
validMods.Sort((prev, next) => SortMods(prev, next, missingFromOrderJSON));
// log the missing mods from order.json
if (logger.IsLogEnabled(LogLevel.Debug))
{
foreach (var missingMod in missingFromOrderJSON.Keys)
{
logger.Debug(localisationService.GetText("modloader-mod_order_missing_from_json", missingMod));
}
}
// add mods
foreach (var mod in validMods)
{
if (ShouldSkipMod(mod.PackageJson))
{
logger.Warning(localisationService.GetText("modloader-skipped_mod", new { mod }));
continue;
}
AddMod(mod);
}
}
protected int SortMods(SptMod prev, SptMod next, Dictionary<string, bool> missingFromOrderJson)
{
// mod is not on the list, move the mod to last
if (!order.TryGetValue(prev.PackageJson!.Name!, out var previndex))
{
missingFromOrderJson[prev.PackageJson.Name!] = true;
return 1;
}
if (!order.TryGetValue(next.PackageJson!.Name!, out var nextindex))
{
missingFromOrderJson[next.PackageJson.Name!] = true;
return -1;
}
return previndex - nextindex;
}
/**
* Check for duplicate mods loaded, show error if any
* @param modPackageData map of mod package.json data
*/
protected void CheckForDuplicateMods(Dictionary<string, PackageJsonData> modPackageData)
{
var grouppedMods = new Dictionary<string, List<PackageJsonData>>();
foreach (var mod in modPackageData.Values)
{
var name = $"{mod.Author}-{mod.Name}";
grouppedMods.Add(name, [..(grouppedMods.GetValueOrDefault(name) ?? []), mod]);
// if there's more than one entry for a given mod it means there's at least 2 mods with the same author and name trying to load.
if (grouppedMods[name].Count > 1 && !skippedMods.Contains(name))
{
skippedMods.Add(name);
}
}
// at this point skippedMods only contains mods that are duplicated, so we can just go through every single entry and log it
foreach (var modName in skippedMods)
{
logger.Error(localisationService.GetText("modloader-x_duplicates_found", modName));
}
}
/**
* Returns an array of valid mods.
*
* @param mods mods to validate
* @returns array of mod folder names
*/
protected List<SptMod> GetValidMods(List<SptMod> mods)
{
return mods.Where(ValidMod).ToList();
}
/**
* Is the passed in mod compatible with the running server version
* @param mod Mod to check compatibiltiy with SPT
* @returns True if compatible
*/
protected bool IsModCombatibleWithSpt(PackageJsonData mod)
{
var sptVersion = ProgramStatics.SPT_VERSION() ?? sptConfig.SptVersion;
var modName = $"{mod.Author}-${mod.Name}";
// Error and prevent loading if sptVersion property is not a valid semver string
if (!(semVer.IsValid(mod.SptVersion) || semVer.IsValidRange(mod.SptVersion)))
{
logger.Error(localisationService.GetText("modloader-invalid_sptversion_field", modName));
return false;
}
// Warning and allow loading if semver is not satisfied
if (!semVer.Satisfies(sptVersion, mod.SptVersion))
{
logger.Error(
localisationService.GetText("modloader-outdated_sptversion_field", new
{
modName = modName,
modVersion = mod.Version,
desiredSptVersion = mod.SptVersion,
})
);
return false;
}
return true;
}
/**
* Read loadorder.json (create if doesnt exist) and return sorted list of mods
* @returns string array of sorted mod names
*/
public List<string> SortModsLoadOrder()
{
// if loadorder.json exists: load it, otherwise generate load order
var loadOrderPath = $"{basepath}loadorder.json";
if (fileUtil.FileExists(loadOrderPath))
{
return jsonUtil.Deserialize<List<string>>(fileUtil.ReadFile(loadOrderPath));
}
return modLoadOrder.GetLoadOrder();
}
/**
* Compile mod and add into class property "imported"
* @param mod Name of mod to compile/add
*/
protected void AddMod(SptMod mod)
{
// Add mod to imported list
imported.Add(mod.PackageJson.Name, mod);
logger.Info(
localisationService.GetText("modloader-loaded_mod", new
{
name = mod.PackageJson.Name,
version = mod.PackageJson.Version,
author = mod.PackageJson.Author,
})
);
}
/**
* Checks if a given mod should be loaded or skipped.
*
* @param pkg mod package.json data
* @returns
*/
protected bool ShouldSkipMod(PackageJsonData pkg)
{
return skippedMods.Contains($"{pkg.Author}-{pkg.Name}");
}
protected bool AreModDependenciesFulfilled(PackageJsonData pkg, Dictionary<string, PackageJsonData> loadedMods)
{
if (pkg.ModDependencies == null)
{
return true;
}
// used for logging, dont remove
var modName = $"{pkg.Author}-{pkg.Name}";
foreach (var (modDependency, requiredVersion) in pkg.ModDependencies)
{
// Raise dependency version incompatible if the dependency is not found in the mod list
if (!loadedMods.ContainsKey(modDependency))
{
logger.Error(
localisationService.GetText("modloader-missing_dependency", new
{
mod = modName,
modDependency = modDependency
})
);
return false;
}
if (!semVer.Satisfies(loadedMods[modDependency].Version, requiredVersion))
{
logger.Error(
localisationService.GetText("modloader-outdated_dependency", new
{
mod = modName,
modDependency = modDependency,
currentVersion = loadedMods[modDependency].Version,
requiredVersion = requiredVersion
})
);
return false;
}
}
return true;
}
protected bool IsModCompatible(PackageJsonData mod, Dictionary<string, PackageJsonData> loadedMods)
{
var incompatbileModsList = mod.Incompatibilities;
if (incompatbileModsList == null)
{
return true;
}
foreach (var incompatibleModName in incompatbileModsList)
{
// Raise dependency version incompatible if any incompatible mod is found
if (loadedMods.ContainsKey(incompatibleModName))
{
logger.Error(
localisationService.GetText("modloader-incompatible_mod_found", new
{
author = mod.Author,
name = mod.Name,
incompatibleModName = incompatibleModName
})
);
return false;
}
}
return true;
}
/**
* Validate a mod passes a number of checks
* @param modName name of mod in /mods/ to validate
* @returns true if valid
*/
protected bool ValidMod(SptMod mod)
{
var modName = mod.PackageJson.Name;
var modPath = GetModPath(modName);
var modIsCalledBepinEx = modName.ToLower() == "bepinex";
var modIsCalledUser = modName.ToLower() == "user";
var modIsCalledSrc = modName.ToLower() == "src";
var modIsCalledDb = modName.ToLower() == "db";
var hasBepinExFolderStructure = fileUtil.DirectoryExists($"{modPath}/plugins");
var containsJs = fileUtil.GetFiles(modPath, true, "*.js").Count > 0;
var containsTs = fileUtil.GetFiles(modPath, true, "*.ts").Count > 0;
if (modIsCalledSrc || modIsCalledDb || modIsCalledUser)
{
logger.Error(localisationService.GetText("modloader-not_correct_mod_folder", modName));
return false;
}
if (modIsCalledBepinEx || hasBepinExFolderStructure)
{
logger.Error(localisationService.GetText("modloader-is_client_mod", modName));
return false;
}
if (containsJs || containsTs)
{
// TODO, needs new localisation!
logger.Error("The mod is an old server mod, JS/TS files detected");
return false;
}
// Validate mod
var config = mod.PackageJson;
var issue = false;
if (!semVer.IsValid(config.Version))
{
logger.Error(localisationService.GetText("modloader-invalid_version_property", modName));
issue = true;
}
return !issue;
}
}
+45 -9
View File
@@ -2,11 +2,17 @@ using System.Runtime;
using Core.Context;
using Core.Helpers;
using Core.Models.External;
using Core.Models.Spt.Mod;
using Core.Models.Utils;
using Core.Utils;
using Serilog;
using Serilog.Events;
using Serilog.Exceptions;
using Serilog.Extensions.Logging;
using Server.Logger;
using Server.Modding;
using SptCommon.Semver;
using SptCommon.Semver.Implementations;
using SptDependencyInjection;
namespace Server;
@@ -15,19 +21,22 @@ public static class Program
{
public static void Main(string[] args)
{
// Search for mod dlls
var mods = ModDllLoader.LoadAllMods();
HarmonyBootstrapper.LoadAllPatches(mods.Select(asm => asm.Assembly).ToList());
var builder = WebApplication.CreateBuilder(args);
builder.Host.UseSerilog();
builder.Configuration.AddJsonFile("appsettings.json", true, true);
CreateAndRegisterLogger(builder, out var registeredLogger);
// Create web builder and logger
var builder = CreateNewHostBuilder(out var registeredLogger, args);
// Initialize the program variables TODO: this needs to be implemented properly
ProgramStatics.Initialize();
// validate and sort mods, this will also discard any mods that are invalid
var sortedLoadedMods = ValidateMods(mods);
// for harmony we use the original list, as some mods may only be bepinex patches only
HarmonyBootstrapper.LoadAllPatches(mods.Select(asm => asm.Assembly).ToList());
// register SPT components
DependencyInjectionRegistrator.RegisterSptComponents(typeof(Program).Assembly, typeof(App).Assembly, builder.Services);
DependencyInjectionRegistrator.RegisterModOverrideComponents(builder.Services, mods.Select(a => a.Assembly).ToList());
// register mod components from the filtered list
DependencyInjectionRegistrator.RegisterModOverrideComponents(builder.Services, sortedLoadedMods.Select(a => a.Assembly).ToList());
var logger = new SerilogLoggerProvider(registeredLogger).CreateLogger("Server");
try
{
@@ -74,6 +83,33 @@ public static class Program
}
}
private static WebApplicationBuilder CreateNewHostBuilder(out Serilog.Core.Logger logger, string[]? args = null)
{
var builder = WebApplication.CreateBuilder(args);
builder.Host.UseSerilog();
builder.Configuration.AddJsonFile("appsettings.json", true, true);
CreateAndRegisterLogger(builder, out logger);
return builder;
}
private static List<SptMod> ValidateMods(List<SptMod> mods)
{
// We need the SPT dependencies for the ModValidator, but mods are loaded before the web application
// So we create a disposable web application that we will throw away after getting the mods to load
var builder = CreateNewHostBuilder(out _);
// register SPT components
DependencyInjectionRegistrator.RegisterSptComponents(typeof(Program).Assembly, typeof(App).Assembly, builder.Services);
// register the mod validator components
var provider = builder.Services
.AddScoped(typeof(ISptLogger<ModValidator>), typeof(SptWebApplicationLogger<ModValidator>))
.AddScoped(typeof(ISemVer), typeof(SemanticVersioningSemVer))
.AddSingleton<ModValidator>()
.AddSingleton<ModLoadOrder>()
.BuildServiceProvider();
var modValidator = provider.GetRequiredService<ModValidator>();
return modValidator.ValidateAndSort(mods);
}
public static void CreateAndRegisterLogger(WebApplicationBuilder builder, out Serilog.Core.Logger logger)
{
builder.Logging.ClearProviders();