diff --git a/source/Reloaded.Mod.Launcher.Lib/Reloaded.Mod.Launcher.Lib.csproj b/source/Reloaded.Mod.Launcher.Lib/Reloaded.Mod.Launcher.Lib.csproj
index 2182b25f..c5a839d0 100644
--- a/source/Reloaded.Mod.Launcher.Lib/Reloaded.Mod.Launcher.Lib.csproj
+++ b/source/Reloaded.Mod.Launcher.Lib/Reloaded.Mod.Launcher.Lib.csproj
@@ -15,6 +15,7 @@
+
all
diff --git a/source/Reloaded.Mod.Launcher.Lib/Utility/JxlImageConverter.cs b/source/Reloaded.Mod.Launcher.Lib/Utility/JxlImageConverter.cs
new file mode 100644
index 00000000..5f9ddaac
--- /dev/null
+++ b/source/Reloaded.Mod.Launcher.Lib/Utility/JxlImageConverter.cs
@@ -0,0 +1,32 @@
+using PhotoSauce.MagicScaler;
+using PhotoSauce.NativeCodecs.Libjxl;
+
+namespace Reloaded.Mod.Launcher.Lib.Utility;
+
+///
+/// Converts image to JPEG XL format.
+///
+public class JxlImageConverter : IImageConverter
+{
+ private static readonly IEncoderOptions EncoderOptions = new JxlLosslessEncoderOptions(JxlEncodeSpeed.Squirrel, JxlDecodeSpeed.Slowest);
+
+ static JxlImageConverter()
+ {
+ CodecManager.Configure(codecs => { codecs.UseLibjxl(); });
+ }
+
+ ///
+ public MemoryStream Convert(Span source, out string extension)
+ {
+ var settings = new ProcessImageSettings();
+ settings.TrySetEncoderFormat(ImageMimeTypes.Jxl);
+ settings.EncoderOptions = EncoderOptions;
+
+ var output = new MemoryStream();
+ MagicImageProcessor.ProcessImage(source, output, settings);
+
+ output.Position = 0;
+ extension = ".jxl";
+ return output;
+ }
+}
\ No newline at end of file
diff --git a/source/Reloaded.Mod.Loader.Tests/Reloaded.Mod.Loader.Tests.csproj b/source/Reloaded.Mod.Loader.Tests/Reloaded.Mod.Loader.Tests.csproj
index e92327fc..10ea4465 100644
--- a/source/Reloaded.Mod.Loader.Tests/Reloaded.Mod.Loader.Tests.csproj
+++ b/source/Reloaded.Mod.Loader.Tests/Reloaded.Mod.Loader.Tests.csproj
@@ -56,6 +56,7 @@
+
diff --git a/source/Reloaded.Mod.Loader.Tests/SETUP/TestEnvironmoent.cs b/source/Reloaded.Mod.Loader.Tests/SETUP/TestEnvironmoent.cs
index 69661f65..d4b59b2e 100644
--- a/source/Reloaded.Mod.Loader.Tests/SETUP/TestEnvironmoent.cs
+++ b/source/Reloaded.Mod.Loader.Tests/SETUP/TestEnvironmoent.cs
@@ -1,4 +1,4 @@
-using Environment = Reloaded.Mod.Shared.Environment;
+using System.Diagnostics;
using Paths = Reloaded.Mod.Loader.IO.Paths;
namespace Reloaded.Mod.Loader.Tests.SETUP;
@@ -92,7 +92,7 @@ public TestEnvironmoent()
ThisApplication = new ApplicationConfig(IdOfThisApp,
"Reloaded Mod Loader Tests",
- Environment.CurrentProcessLocation.Value,
+ Path.GetFullPath(Process.GetCurrentProcess().MainModule!.FileName!),
new[] { TestModConfigA.ModId, TestModConfigB.ModId, TestModConfigD.ModId });
ConfigurationPathOfThisApp = Path.Combine(TestConfig.GetApplicationConfigDirectory(), IdOfThisApp, ApplicationConfig.ConfigFileName);
diff --git a/source/Reloaded.Mod.Loader.Tests/Update/Pack/AutoPackageCreatorTests.cs b/source/Reloaded.Mod.Loader.Tests/Update/Pack/AutoPackageCreatorTests.cs
new file mode 100644
index 00000000..230c70f5
--- /dev/null
+++ b/source/Reloaded.Mod.Loader.Tests/Update/Pack/AutoPackageCreatorTests.cs
@@ -0,0 +1,76 @@
+using Reloaded.Mod.Launcher.Lib.Utility;
+using Reloaded.Mod.Loader.Tests.Update.Pack.Mocks;
+using Reloaded.Mod.Loader.Update.Packs;
+using Sewer56.Update.Resolvers.GameBanana;
+
+namespace Reloaded.Mod.Loader.Tests.Update.Pack;
+
+public class AutoPackageCreatorTests
+{
+ [Fact]
+ public void CannotCreate_WithMissingUpdateInfo()
+ {
+ using var tempFolderAlloc = new TemporaryFolderAllocation();
+ var conf = CreateTestConfig(tempFolderAlloc.FolderPath);
+
+ Assert.False(AutoPackCreator.ValidateCanCreate(new []{ conf }, out var incompatible));
+ Assert.Single(incompatible);
+ }
+
+ [Fact]
+ public void CanCreate_WithUpdateInfo()
+ {
+ using var tempFolderAlloc = new TemporaryFolderAllocation();
+ var conf = CreateTestConfig(tempFolderAlloc.FolderPath);
+ AddGitHubUpdateResolver(conf);
+
+ Assert.True(AutoPackCreator.ValidateCanCreate(new []{ conf }, out var incompatible));
+ Assert.Empty(incompatible);
+ }
+
+ [Fact]
+ public async Task AutoPackCreator_CanImportFromModSearch()
+ {
+ // Arrange
+ using var tempMod = new TemporaryFolderAllocation();
+ var modConf = CreateTestConfig(tempMod.FolderPath);
+ AddGitHubUpdateResolver(modConf);
+
+ var provider = GetGameBananaPackageProvider();
+
+ // Act
+ var pack = await AutoPackCreator.CreateAsync(new ModConfig[] { modConf.Config },
+ new DummyImageConverter(),
+ new List() { provider});
+ var built = pack.Build(out var package);
+
+ // Assert
+ File.WriteAllBytes("build.zip", built.ToArray());
+ Assert.Single(package.Items);
+ Assert.True(package.Items[0].ImageFiles.Count > 1);
+ }
+
+ private PathTuple CreateTestConfig(string folder)
+ {
+ return new PathTuple(Path.Combine(folder, ModConfig.ConfigFileName), new ModConfig()
+ {
+ ModId = "sonicheroes.essentials.graphics",
+ ModName = "Graphics Essentials" // so it finds on GameBanana
+ });
+ }
+
+ private void AddGitHubUpdateResolver(PathTuple modTuple)
+ {
+ var factory = new GitHubReleasesUpdateResolverFactory();
+ factory.SetConfiguration(modTuple, new GitHubReleasesUpdateResolverFactory.GitHubConfig()
+ {
+ RepositoryName = "Heroes.Graphics.Essentials.ReloadedII",
+ UserName = "Sewer56"
+ });
+ }
+
+ private GameBananaPackageProvider GetGameBananaPackageProvider()
+ {
+ return new GameBananaPackageProvider(6061);
+ }
+}
\ No newline at end of file
diff --git a/source/Reloaded.Mod.Loader.Tests/Update/Pack/Mocks/DummyImageConverter.cs b/source/Reloaded.Mod.Loader.Tests/Update/Pack/Mocks/DummyImageConverter.cs
new file mode 100644
index 00000000..e60f1b05
--- /dev/null
+++ b/source/Reloaded.Mod.Loader.Tests/Update/Pack/Mocks/DummyImageConverter.cs
@@ -0,0 +1,10 @@
+namespace Reloaded.Mod.Loader.Tests.Update.Pack.Mocks;
+
+public class DummyImageConverter : IImageConverter
+{
+ public MemoryStream Convert(Span source, out string extension)
+ {
+ extension = ".org";
+ return new MemoryStream(source.ToArray());
+ }
+}
\ No newline at end of file
diff --git a/source/Reloaded.Mod.Loader.Update/Interfaces/IImageConverter.cs b/source/Reloaded.Mod.Loader.Update/Interfaces/IImageConverter.cs
new file mode 100644
index 00000000..8e9e87cf
--- /dev/null
+++ b/source/Reloaded.Mod.Loader.Update/Interfaces/IImageConverter.cs
@@ -0,0 +1,15 @@
+namespace Reloaded.Mod.Loader.Update.Interfaces;
+
+///
+/// Interface that can be used to provide support for converting images.
+///
+public interface IImageConverter
+{
+ ///
+ /// Converts the image to new format.
+ ///
+ /// Span containing the source image to be converted.
+ /// The extension for the image.
+ /// The converted image in a stream. Stream should have position 0 and end at end of file.
+ public MemoryStream Convert(Span source, out string extension);
+}
\ No newline at end of file
diff --git a/source/Reloaded.Mod.Loader.Update/Packs/AutoPackCreator.cs b/source/Reloaded.Mod.Loader.Update/Packs/AutoPackCreator.cs
new file mode 100644
index 00000000..075e8c8d
--- /dev/null
+++ b/source/Reloaded.Mod.Loader.Update/Packs/AutoPackCreator.cs
@@ -0,0 +1,135 @@
+namespace Reloaded.Mod.Loader.Update.Packs;
+
+///
+/// Utility class for automatic creation of packages based on a given sample input of mods.
+///
+public static class AutoPackCreator
+{
+ ///
+ /// Checks if all mods in given list can be used in the pack by verifying they have enabled updates.
+ ///
+ /// List of mod configurations to check.
+ /// List of mods to check for compatibility.
+ /// True if mods can be packed, else false.
+ public static bool ValidateCanCreate(IEnumerable> configurations, out List> incompatibleMods)
+ {
+ incompatibleMods = new List>();
+ foreach (var config in configurations)
+ {
+ if (!PackageResolverFactory.HasAnyConfiguredResolver(config))
+ incompatibleMods.Add(config);
+ }
+
+ return incompatibleMods.Count <= 0;
+ }
+
+ ///
+ /// Automatically creates a package.
+ ///
+ /// The configurations used to create the config. Must at least have update data, ModId and ModName.
+ /// Used for converting images.
+ /// Providers that can be used to search for packages.
+ /// Token to cancel the operation.
+ public static async Task CreateAsync(IEnumerable configurations, IImageConverter imageConverter, IList packageProviders, CancellationToken token = default)
+ {
+ var builder = new ReloadedPackBuilder();
+ var imageDownloader = new ImageCacheService();
+
+ builder.SetName("My Autogenerated Package");
+ builder.SetReadme("You should probably add description here.");
+
+ foreach (var config in configurations)
+ {
+ var itemBuilder = builder.AddModItem(config.ModId);
+ itemBuilder.SetName(config.ModName);
+ itemBuilder.SetPluginData(config.PluginData);
+
+ var bestPkg = await GetBestPackageForTemplateAsync(config.ModId, config.ModName, packageProviders, token);
+
+ // Add images if possible
+ if (bestPkg.images != null)
+ {
+ foreach (var image in bestPkg.images)
+ {
+ var file = await imageDownloader.GetOrDownloadFileFromUrl(image.Uri, imageDownloader.ModPreviewExpiration, false, token);
+ var converted = imageConverter.Convert(file, out string ext);
+ itemBuilder.AddImage(converted, ext, image.Caption);
+ }
+ }
+
+ // Add readme if possible
+ if (!string.IsNullOrEmpty(bestPkg.markdownReadme))
+ itemBuilder.SetReadme(bestPkg.markdownReadme);
+ }
+
+ return builder;
+ }
+
+ private static async Task<(DownloadableImage[]? images, string markdownReadme)> GetBestPackageForTemplateAsync(string modId, string modName,
+ IList downloadablePackageProviders, CancellationToken cancellationToken)
+ {
+ (DownloadableImage[]? images, string markdownReadme) result = new();
+
+ // We will get all packages with highest version and copy images & readme down the road.
+ NuGetVersion? highestVersion = null;
+ List itemsForHighestVersion = new List();
+
+ // Note: Some sources don't support e.g. images.
+ // We prioritise sources based on whether they contain images, then by readme.
+ // If preferred source does not contain both readme and image, we stitch them together from sources that do.
+ foreach (var provider in downloadablePackageProviders)
+ {
+ // Override if better than current best.
+ var candidates = await provider.SearchForModAsync(modId, modName, 50, 4, true, cancellationToken);
+ foreach (var candidate in candidates)
+ {
+ // Find items for highest version
+ if (candidate.Version != null)
+ {
+ if (highestVersion == null)
+ highestVersion = candidate.Version;
+
+ if (candidate.Version == highestVersion)
+ itemsForHighestVersion.Add(candidate);
+
+ if (candidate.Version > highestVersion)
+ {
+ itemsForHighestVersion.Clear();
+ highestVersion = candidate.Version;
+ itemsForHighestVersion.Add(candidate);
+ }
+ }
+
+ // Set images if more than existing best result.
+ if (candidate.Images != null)
+ {
+ // Assign images if unassigned
+ result.images ??= candidate.Images;
+
+ // Prefer source with more images.
+ if (candidate.Images.Length > result.images.Length)
+ result.images = candidate.Images;
+ }
+
+ // Set description if unassigned.
+ if (candidate.MarkdownReadme != null)
+ result.markdownReadme ??= candidate.MarkdownReadme;
+ }
+ }
+
+ // Override supported items from
+ foreach (var item in itemsForHighestVersion)
+ {
+ // Set description if unassigned.
+ if (item.MarkdownReadme != null)
+ result.markdownReadme = item.MarkdownReadme;
+
+ // Set images to ones from newest version in case older version had more images.
+ // But only if more than 1 image. We want to filter out entries with only thumbnail.
+ if (item.Images is { Length: > 1 })
+ result.images = item.Images;
+ }
+
+ return result;
+ }
+}
\ No newline at end of file