diff --git a/.github/workflows/create-draft-preview.yml b/.github/workflows/create-draft-preview.yml index 8788f6a..50f4337 100644 --- a/.github/workflows/create-draft-preview.yml +++ b/.github/workflows/create-draft-preview.yml @@ -1,6 +1,7 @@ name: Prepare Preview on: - workflow_dispatch: + + workflow_dispatch: inputs: name: description: "Release name" @@ -23,7 +24,7 @@ jobs: steps: - name: Checkout Repo uses: actions/checkout@v6 - + - name: Checkout AppCast Repo uses: actions/checkout@v6 with: diff --git a/Docs/ML Format.md b/Docs/ML Format.md new file mode 100644 index 0000000..9f313e2 --- /dev/null +++ b/Docs/ML Format.md @@ -0,0 +1,34 @@ +> All byte values are little-endian + +### ModList Format + +| Segment | Value / Range | Length | Type | Encoding / Details | +|:---------------|:--------------|:---------|:----------|:-----------------------------------------------------------| +| Signature | "SRMM_ML" | 7 bytes | char[] | ASCII text, hex: `53 52 4D 4D 5F 4D 4C` | +| Version | 0 - 255 | 1 byte | uint8 | File format version | +| Active Profile | 0 - 7 | 1 byte | uint8 | Active profile index (value + 1 = profile, 0 -> Profile 1) | +| Mod Count | 0 - 65,535 | 2 bytes | uint16 | Number of mod entries | +| Mod Entries | Variable | Variable | `ModInfo` | Repeats `Mod Count` times | + + +### ModInfo Format + +| Segment | Value / Range | Length | Type | Encoding / Details | +|:-----------------|:-------------------|:---------|:-------|:-------------------------------------------------------| +| Enabled Profiles | `Enabled Profiles` | 1 byte | uint8 | Bitmask representing profiles where the mod is enabled | +| Name Length | 0 - 65,535 | 2 bytes | utin16 | Length of mod name in bytes | +| Mod Name | Variable | Variable | char[] | UTF8 encoded text, length = `Name Length` | + + +### Enabled Profiles Format + +| Bit | Mask | Profile | +|:----|:-----|:----------| +| 0 | 0x01 | Profile 1 | +| 1 | 0x02 | Profile 2 | +| 2 | 0x04 | Profile 3 | +| 3 | 0x08 | Profile 4 | +| 4 | 0x10 | Profile 5 | +| 5 | 0x20 | Profile 6 | +| 6 | 0x40 | Profile 7 | +| 7 | 0x80 | Profile 8 | \ No newline at end of file diff --git a/ShinRyuModManager-CE/Attributes/DescriptionAttribute.cs b/ShinRyuModManager-CE/Attributes/DescriptionAttribute.cs new file mode 100644 index 0000000..3f5db38 --- /dev/null +++ b/ShinRyuModManager-CE/Attributes/DescriptionAttribute.cs @@ -0,0 +1,6 @@ +namespace ShinRyuModManager.Attributes; + +[AttributeUsage(AttributeTargets.Field)] +public class DescriptionAttribute(string name) : Attribute { + public string Name { get; } = name; +} diff --git a/ShinRyuModManager-CE/Exceptions/ModListFileLoadException.cs b/ShinRyuModManager-CE/Exceptions/ModListFileLoadException.cs new file mode 100644 index 0000000..dde9f85 --- /dev/null +++ b/ShinRyuModManager-CE/Exceptions/ModListFileLoadException.cs @@ -0,0 +1,9 @@ +namespace ShinRyuModManager.Exceptions; + +public class ModListFileLoadException : Exception { + public ModListFileLoadException() { } + + public ModListFileLoadException(string message) : base(message) { } + + public ModListFileLoadException(string message, Exception inner) : base(message, inner) { } +} diff --git a/ShinRyuModManager-CE/Extensions/ProfileExtensions.cs b/ShinRyuModManager-CE/Extensions/ProfileExtensions.cs new file mode 100644 index 0000000..d8c4ba0 --- /dev/null +++ b/ShinRyuModManager-CE/Extensions/ProfileExtensions.cs @@ -0,0 +1,22 @@ +using System.Reflection; +using ShinRyuModManager.Attributes; +using ShinRyuModManager.ModLoadOrder.Mods; + +namespace ShinRyuModManager.Extensions; + +public static class ProfileExtensions { + public static ProfileMask ToMask(this Profile profile) { + return (ProfileMask)(1 << (int)profile); + } + + public static bool AppliesTo(this ProfileMask mask, Profile profile) { + return mask.HasFlag(profile.ToMask()); + } + + public static string GetDescription(this Profile profile) { + var field = profile.GetType().GetField(profile.ToString()); + var attr = field?.GetCustomAttribute(); + + return attr?.Name ?? profile.ToString(); + } +} diff --git a/ShinRyuModManager-CE/Language/Views/MainWindow.Designer.cs b/ShinRyuModManager-CE/Language/Views/MainWindow.Designer.cs index df46347..1f70f12 100644 --- a/ShinRyuModManager-CE/Language/Views/MainWindow.Designer.cs +++ b/ShinRyuModManager-CE/Language/Views/MainWindow.Designer.cs @@ -206,5 +206,59 @@ public static string ModUp_Tooltip { return ResourceManager.GetString("ModUp_Tooltip", resourceCulture); } } + + public static string Menu_Profiles { + get { + return ResourceManager.GetString("Menu_Profiles", resourceCulture); + } + } + + public static string Menu_Profile1 { + get { + return ResourceManager.GetString("Menu_Profile1", resourceCulture); + } + } + + public static string Menu_Profile2 { + get { + return ResourceManager.GetString("Menu_Profile2", resourceCulture); + } + } + + public static string Menu_Profile3 { + get { + return ResourceManager.GetString("Menu_Profile3", resourceCulture); + } + } + + public static string Menu_Profile4 { + get { + return ResourceManager.GetString("Menu_Profile4", resourceCulture); + } + } + + public static string Menu_Profile5 { + get { + return ResourceManager.GetString("Menu_Profile5", resourceCulture); + } + } + + public static string Menu_Profile6 { + get { + return ResourceManager.GetString("Menu_Profile6", resourceCulture); + } + } + + public static string Menu_Profile7 { + get { + return ResourceManager.GetString("Menu_Profile7", resourceCulture); + } + } + + public static string Menu_Profile8 { + get { + return ResourceManager.GetString("Menu_Profile8", resourceCulture); + } + } } } diff --git a/ShinRyuModManager-CE/Language/Views/MainWindow.resx b/ShinRyuModManager-CE/Language/Views/MainWindow.resx index 1ed46c4..6acab46 100644 --- a/ShinRyuModManager-CE/Language/Views/MainWindow.resx +++ b/ShinRyuModManager-CE/Language/Views/MainWindow.resx @@ -99,4 +99,31 @@ Move the selected mod(s) up + + Profiles + + + Profile 1 + + + Profile 2 + + + Profile 3 + + + Profile 4 + + + Profile 5 + + + Profile 6 + + + Profile 7 + + + Profile 8 + \ No newline at end of file diff --git a/ShinRyuModManager-CE/ModLoadOrder/Generator.cs b/ShinRyuModManager-CE/ModLoadOrder/Generator.cs index 846b9ac..1039ea9 100644 --- a/ShinRyuModManager-CE/ModLoadOrder/Generator.cs +++ b/ShinRyuModManager-CE/ModLoadOrder/Generator.cs @@ -6,7 +6,7 @@ namespace ShinRyuModManager.ModLoadOrder; public static class Generator { - public static async Task GenerateModeLoadOrder(List mods, bool looseFilesEnabled, bool cpkRepackingEnabled) { + public static async Task GenerateModeLoadOrder(List mods, bool looseFilesEnabled, bool cpkRepackingEnabled) { List modIndices = [0]; var files = new OrderedSet(); var modsWithFoldersNotFound = new Dictionary>(); // Dict of Mod, ListOfFolders @@ -42,7 +42,7 @@ public static async Task GenerateModeLoadOrder(List mods, bool loos // Use a reverse loop to be able to remove items from the list when necessary for (var i = mods.Count - 1; i >= 0; i--) { var mod = new Mod(mods[i]); - var modPath = GamePath.GetModDirectory(mods[i]); + var modPath = GamePath.GetModDirectory(mod.Name); mod.AddFiles(modPath, ""); @@ -104,9 +104,11 @@ public static async Task GenerateModeLoadOrder(List mods, bool loos mods.Reverse(); Log.Information($"Generating {Constants.MLO} file..."); + + var modNames = mods.Select(x => x.Name).ToList(); // Generate MLO - var mlo = new MLO(modIndices, mods, files, loose.ParlessFolders, cpkDictionary); + var mlo = new MLO(modIndices, modNames, files, loose.ParlessFolders, cpkDictionary); mlo.WriteMLO(Path.Combine(GamePath.FullGamePath, Constants.MLO)); @@ -114,7 +116,7 @@ public static async Task GenerateModeLoadOrder(List mods, bool loos // Check if a mod has a par that will override the repacked par, and skip repacking it in that case foreach (var key in parDictionary.Keys.ToList()) { - var value = parDictionary[key]; + var value = new ModInfo(parDictionary[key][0], 0); // Faster lookup by checking in the OrderedSet if (!files.Contains($"{key}.par")) @@ -124,7 +126,7 @@ public static async Task GenerateModeLoadOrder(List mods, bool loos var matchIndex = mlo.Files.Find(f => f.Name == Utils.NormalizeToNodePath($"{key}.par")).Index; // Avoid repacking pars which exist as a file in mods that have a higher priority that the first mod in the par to be repacked - if (mods.IndexOf(value[0]) > matchIndex) { + if (mods.IndexOf(value) > matchIndex) { parDictionary.Remove(key); } } diff --git a/ShinRyuModManager-CE/ModLoadOrder/Mods/Mod.cs b/ShinRyuModManager-CE/ModLoadOrder/Mods/Mod.cs index dbf9c82..5982bf7 100644 --- a/ShinRyuModManager-CE/ModLoadOrder/Mods/Mod.cs +++ b/ShinRyuModManager-CE/ModLoadOrder/Mods/Mod.cs @@ -26,6 +26,8 @@ public class Mod { /// Folders that need to be repacked. /// public List RepackCpKs { get; } + + public Mod(ModInfo modInfo) : this(modInfo.Name) { } public Mod(string name) { Name = name; diff --git a/ShinRyuModManager-CE/ModLoadOrder/Mods/ModInfo.cs b/ShinRyuModManager-CE/ModLoadOrder/Mods/ModInfo.cs index 8b728ba..07ae7e6 100644 --- a/ShinRyuModManager-CE/ModLoadOrder/Mods/ModInfo.cs +++ b/ShinRyuModManager-CE/ModLoadOrder/Mods/ModInfo.cs @@ -1,24 +1,32 @@ using CommunityToolkit.Mvvm.ComponentModel; -using Utils; +using ShinRyuModManager.Extensions; namespace ShinRyuModManager.ModLoadOrder.Mods; public sealed partial class ModInfo : ObservableObject, IEquatable { [ObservableProperty] private string _name; - [ObservableProperty] private bool _enabled; - public ModInfo(string name, bool enabled = true) { + public ProfileMask EnabledProfiles { get; set; } + + public bool Enabled { + get => EnabledProfiles.AppliesTo(Program.ActiveProfile); + set { + if (value) { + EnabledProfiles |= Program.ActiveProfile.ToMask(); + } else { + EnabledProfiles &= ~Program.ActiveProfile.ToMask(); + } + } + } + + public ModInfo(string name, ProfileMask enabledMask = ProfileMask.All) { Name = name; - Enabled = enabled; + EnabledProfiles = enabledMask; } public void ToggleEnabled() { Enabled = !Enabled; } - - public static bool IsValid(ModInfo info) { - return (info != null) && Directory.Exists(Path.Combine(GamePath.MODS, info.Name)); - } public bool Equals(ModInfo other) { if (other is null) @@ -44,6 +52,6 @@ public override bool Equals(object obj) { } public override int GetHashCode() { - return HashCode.Combine(Name, Enabled); + return Name.GetHashCode(); } } diff --git a/ShinRyuModManager-CE/ModLoadOrder/Mods/Profile.cs b/ShinRyuModManager-CE/ModLoadOrder/Mods/Profile.cs new file mode 100644 index 0000000..079b8ec --- /dev/null +++ b/ShinRyuModManager-CE/ModLoadOrder/Mods/Profile.cs @@ -0,0 +1,22 @@ +using ShinRyuModManager.Attributes; + +namespace ShinRyuModManager.ModLoadOrder.Mods; + +public enum Profile : byte { + [Description("Profile 1")] + Profile1 = 0, + [Description("Profile 2")] + Profile2 = 1, + [Description("Profile 3")] + Profile3 = 2, + [Description("Profile 4")] + Profile4 = 3, + [Description("Profile 5")] + Profile5 = 4, + [Description("Profile 6")] + Profile6 = 5, + [Description("Profile 7")] + Profile7 = 6, + [Description("Profile 8")] + Profile8 = 7 +} diff --git a/ShinRyuModManager-CE/ModLoadOrder/Mods/ProfileMask.cs b/ShinRyuModManager-CE/ModLoadOrder/Mods/ProfileMask.cs new file mode 100644 index 0000000..b302701 --- /dev/null +++ b/ShinRyuModManager-CE/ModLoadOrder/Mods/ProfileMask.cs @@ -0,0 +1,14 @@ +namespace ShinRyuModManager.ModLoadOrder.Mods; + +[Flags] +public enum ProfileMask : byte { + Profile1 = 1 << 0, + Profile2 = 1 << 1, + Profile3 = 1 << 2, + Profile4 = 1 << 3, + Profile5 = 1 << 4, + Profile6 = 1 << 5, + Profile7 = 1 << 6, + Profile8 = 1 << 7, + All = Profile1 | Profile2 | Profile3 | Profile4 | Profile5 | Profile6 | Profile7 | Profile8 +} diff --git a/ShinRyuModManager-CE/ModLoadOrder/Mods/Serialization/ModListSerializer.V0.cs b/ShinRyuModManager-CE/ModLoadOrder/Mods/Serialization/ModListSerializer.V0.cs new file mode 100644 index 0000000..5529836 --- /dev/null +++ b/ShinRyuModManager-CE/ModLoadOrder/Mods/Serialization/ModListSerializer.V0.cs @@ -0,0 +1,29 @@ +using Utils; + +namespace ShinRyuModManager.ModLoadOrder.Mods.Serialization; + +// Old mod list format (ModLoadOrder.txt) +public static partial class ModListSerializer { + private static List ReadV0(string path) { + var mods = new List(); + + foreach (var line in File.ReadLines(path)) { + if (line.StartsWith(';')) + continue; + + var sanitizedLine = line.Split(';', 1, StringSplitOptions.TrimEntries)[0]; + + if (string.IsNullOrEmpty(sanitizedLine) || !Directory.Exists(GamePath.GetModDirectory(sanitizedLine))) + continue; + + var entry = new ModInfo(sanitizedLine); + + if (mods.Contains(entry)) + continue; + + mods.Add(entry); + } + + return mods; + } +} diff --git a/ShinRyuModManager-CE/ModLoadOrder/Mods/Serialization/ModListSerializer.V1.cs b/ShinRyuModManager-CE/ModLoadOrder/Mods/Serialization/ModListSerializer.V1.cs new file mode 100644 index 0000000..1f00fcd --- /dev/null +++ b/ShinRyuModManager-CE/ModLoadOrder/Mods/Serialization/ModListSerializer.V1.cs @@ -0,0 +1,35 @@ +using Utils; + +namespace ShinRyuModManager.ModLoadOrder.Mods.Serialization; + +// Original SRMM mod list format (ModList.txt) +public static partial class ModListSerializer { + private static List ReadV1(string path) { + var mods = new List(); + + var modContent = File.ReadAllText(path); + + if (string.IsNullOrWhiteSpace(modContent)) + return mods; + + foreach (var mod in modContent.Split('|', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) { + if (!mod.StartsWith('<') && !mod.StartsWith('>')) + continue; + + var enabledMask = mod[0] == '<' ? ProfileMask.All : 0; + var modName = mod[1..]; + + if (!Directory.Exists(GamePath.GetModDirectory(modName))) + continue; + + var entry = new ModInfo(modName, enabledMask); + + if (mods.Contains(entry)) + continue; + + mods.Add(entry); + } + + return mods; + } +} diff --git a/ShinRyuModManager-CE/ModLoadOrder/Mods/Serialization/ModListSerializer.V2.cs b/ShinRyuModManager-CE/ModLoadOrder/Mods/Serialization/ModListSerializer.V2.cs new file mode 100644 index 0000000..7e7e397 --- /dev/null +++ b/ShinRyuModManager-CE/ModLoadOrder/Mods/Serialization/ModListSerializer.V2.cs @@ -0,0 +1,37 @@ +using System.Text; + +namespace ShinRyuModManager.ModLoadOrder.Mods.Serialization; + +// Current binary mod list format (ModList.ml) +public static partial class ModListSerializer { + private static List ReadV2(BinaryReader reader, Profile? profile = null) { + var readProfile = (Profile)reader.ReadByte(); + + if (profile == null) { + Program.ActiveProfile = readProfile; + } + + var entryCount = reader.ReadUInt16(); + + var mods = new List(); + + for (var i = 0; i < entryCount; i++) { + var entry = ReadEntryV2(reader); + + mods.Add(entry); + } + + return mods; + } + + private static ModInfo ReadEntryV2(BinaryReader reader) { + var mask = (ProfileMask)reader.ReadByte(); + var nameLength = reader.ReadUInt16(); + + ReadOnlySpan nameBytes = reader.ReadBytes(nameLength); + + var name = Encoding.UTF8.GetString(nameBytes); + + return new ModInfo(name, mask); + } +} diff --git a/ShinRyuModManager-CE/ModLoadOrder/Mods/Serialization/ModListSerializer.cs b/ShinRyuModManager-CE/ModLoadOrder/Mods/Serialization/ModListSerializer.cs new file mode 100644 index 0000000..24ea12c --- /dev/null +++ b/ShinRyuModManager-CE/ModLoadOrder/Mods/Serialization/ModListSerializer.cs @@ -0,0 +1,78 @@ +using System.Text; +using ShinRyuModManager.Exceptions; +using Utils; + +namespace ShinRyuModManager.ModLoadOrder.Mods.Serialization; + +public static partial class ModListSerializer { + private const string SIGNATURE = "SRMM_ML"; + + private static readonly byte[] SignatureBytes = Encoding.UTF8.GetBytes(SIGNATURE); + + public static List Read(string path, Profile? profile = null) { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + + if (!File.Exists(path)) + throw new FileNotFoundException("ModList file not found!", path); + + switch (path) { + case Constants.TXT_OLD: + return ReadV0(path); + case Constants.TXT: + return ReadV1(path); + } + + using var fs = File.OpenRead(path); + using var reader = new BinaryReader(fs, Encoding.UTF8, true); + + ValidateModList(reader); + + var version = reader.ReadByte(); + + // No V0 or V1 as that's handled before + return version switch { + 2 => ReadV2(reader, profile), + _ => throw new NotSupportedException() + }; + } + + // Write methods ALWAYS use the newest format. Never need to be in partial classes + public static void Write(string path, List mods) { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + + var fileExists = File.Exists(path); + var mode = fileExists ? FileMode.Truncate : FileMode.CreateNew; + + using var fs = new FileStream(path, mode, FileAccess.Write, FileShare.None); + using var writer = new BinaryWriter(fs, Encoding.UTF8, true); + + writer.Write(SignatureBytes); + writer.Write(Program.CurrentModListVersion); + writer.Write((byte)Program.ActiveProfile); + writer.Write((ushort)mods.Count); + + foreach (var modInfo in mods) { + WriteEntry(writer, modInfo); + } + } + + private static void WriteEntry(BinaryWriter writer, ModInfo modInfo) { + ReadOnlySpan nameBytes = Encoding.UTF8.GetBytes(modInfo.Name); + + writer.Write((byte)modInfo.EnabledProfiles); + writer.Write((ushort)nameBytes.Length); + writer.Write(nameBytes); + } + + private static void ValidateModList(BinaryReader reader) { + ArgumentNullException.ThrowIfNull(reader); + + reader.BaseStream.Seek(0, SeekOrigin.Begin); + + var sigBytes = reader.ReadBytes(SIGNATURE.Length); + + if (!sigBytes.SequenceEqual(SignatureBytes)) { + throw new ModListFileLoadException("File signature doesn't match!"); + } + } +} diff --git a/ShinRyuModManager-CE/Program.cs b/ShinRyuModManager-CE/Program.cs index 6651da1..5705b6d 100644 --- a/ShinRyuModManager-CE/Program.cs +++ b/ShinRyuModManager-CE/Program.cs @@ -1,6 +1,5 @@ using System.Diagnostics; using System.IO.Compression; -using System.Text; using Avalonia; using Avalonia.Svg.Skia; using IniParser; @@ -14,6 +13,7 @@ using ShinRyuModManager.Helpers; using ShinRyuModManager.ModLoadOrder; using ShinRyuModManager.ModLoadOrder.Mods; +using ShinRyuModManager.ModLoadOrder.Mods.Serialization; using ShinRyuModManager.Templates; using ShinRyuModManager.UserInterface; using Utils; @@ -27,7 +27,6 @@ public static class Program { private static bool _cpkRepackingEnabled = true; private static bool _checkForUpdates = true; private static bool _isSilent; - private static bool _migrated; private static IniData _iniData; private static readonly FileIniDataParser IniParser = new() { @@ -38,6 +37,10 @@ public static class Program { } }; + // Mod List file information + public static byte CurrentModListVersion { get => 2; } + public static Profile ActiveProfile { get; set; } = Profile.Profile1; + public static bool RebuildMlo { get; private set; } = true; public static bool IsRebuildMloSupported { get; private set; } = true; public static LogEventLevel LogLevel { get; private set; } = LogEventLevel.Information; @@ -67,7 +70,6 @@ private static void Main(string[] args) { .WriteTo.Async(a => a.File(new JsonFormatter(renderMessage: true), errorLogsPath, rollingInterval: RollingInterval.Day))) .CreateLogger(); - // TODO: Maybe temporary, YakuzaParless.asi currently only supports the Windows binary. Currently disabling RebuildMLO on Linux if (!OperatingSystem.IsWindows()) { IsRebuildMloSupported = false; } @@ -75,10 +77,6 @@ private static void Main(string[] args) { // Check if there are any args, if so, run in CLI mode // Unfortunately, no one way to detect left Ctrl while being cross-platform if (args.Length == 0) { - if (_checkForUpdates) { - // TODO: Implement updates - } - Log.Information("Shin Ryu Mod Manager GUI Application Start"); BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); @@ -111,31 +109,37 @@ private static async Task MainCLI(string[] args) { Usage: run without arguments to generate mod load order. -s, --silent prevent checking for updates and remove prompts. -h, --help show this message and exit. + -r, --run run the game after the program finishes. """); - //Log.Information(" run with \"-r\" or \"--run\" flag to run the game after the program finishes."); - return; } if (list.Contains("-s") || list.Contains("--silent")) { _isSilent = true; } + + await RunGeneration(PreRun().Where(x => x.Enabled).ToList()); - await RunGeneration(ConvertNewToOldModList(PreRun())); PostRun(); await Log.CloseAndFlushAsync(); - // TODO: Update with logic from the UI - /*if (list.Contains("-r") || list.Contains("--run")) { - if (File.Exists(GamePath.GameExe)) { - Console.WriteLine($"Launching \"{GamePath.GameExe}\"..."); - Process.Start(GamePath.GameExe); + if (list.Contains("-r") || list.Contains("--run")) { + var gameLaunchPath = GamePath.GetGameLaunchPath(); + + if (!string.IsNullOrEmpty(gameLaunchPath)) { + if (OperatingSystem.IsWindows()) { + Process.Start(new ProcessStartInfo(gameLaunchPath) { + UseShellExecute = true + }); + } else if (OperatingSystem.IsLinux()) { + Process.Start("xdg-open", gameLaunchPath); + } } else { - Console.WriteLine($"Warning: Could not run game because \"{GamePath.GameExe}\" does not exist."); + Log.Error("The game can't be launched. Please launch manually."); } - }*/ + } } private static void LoadConfig() { @@ -187,12 +191,14 @@ private static void LoadConfig() { } } - internal static List PreRun() { + internal static List PreRun(Profile? profile = null) { if (GamePath.CurrentGame != Game.Unsupported) { Directory.CreateDirectory(GamePath.MODS); Directory.CreateDirectory(GamePath.LIBRARIES); } + Log.Information("Active Profile: {Profile}", profile?.GetDescription() ?? ActiveProfile.GetDescription()); + // TODO: Maybe move this to a separate "Game patches" file // Virtua Fighter eSports crashes when used with dinput8.dll as the ASI loader if (GamePath.CurrentGame == Game.Eve && File.Exists(Constants.DINPUT8DLL)) { @@ -240,42 +246,43 @@ internal static List PreRun() { IniParser.WriteFile(Constants.INI, _iniData); RebuildMlo = false; } - + var mods = new List(); if (ShouldBeExternalOnly()) { // Only load the files inside the external mods path, and ignore the load order in the txt - mods.Add(new ModInfo(Constants.EXTERNAL_MODS)); + mods.Add(new ModInfo(Constants.EXTERNAL_MODS, 0)); } else { - var defaultEnabled = true; - - if (File.Exists(Constants.TXT_OLD) && _iniData.GetKey("SavedSettings.ModListImported") == null) { - // Scanned mods should be disabled, because that's how they were with the old txt format - defaultEnabled = false; + if (File.Exists(Constants.TXT_OLD)) { + mods.AddRange(ModListSerializer.Read(Constants.TXT_OLD, profile)); - // Set a flag so we can delete the old file after we actually save the mod list - _migrated = true; - - // Migrate old format to new - Log.Information("Old format load order file ({TxtOld}) was found. Importing to the new format...", Constants.TXT_OLD); - - mods.AddRange(ConvertOldToNewModList(ReadModLoadOrderTxt(Constants.TXT_OLD)) - .Where(n => !mods.Any(m => EqualModNames(m.Name, n.Name)))); + File.Move(Constants.TXT_OLD, $"{Constants.TXT_OLD}.bak"); } else if (File.Exists(Constants.TXT)) { - mods.AddRange(ReadModListTxt(Constants.TXT).Where(n => !mods.Any(m => EqualModNames(m.Name, n.Name)))); + mods.AddRange(ModListSerializer.Read(Constants.TXT, profile)); + + File.Move(Constants.TXT, $"{Constants.TXT}.bak"); + } else if (File.Exists(Constants.MOD_LIST)) { + mods.AddRange(ModListSerializer.Read(Constants.MOD_LIST, profile)); } else { - Log.Information($"{Constants.TXT} was not found. Will load all existing mods.\n"); + Log.Information($"{Constants.MOD_LIST} was not found. Will load all existing mods.\n"); } - - if (Directory.Exists(GamePath.MODS)) { + + if (Directory.Exists(GamePath.ModsPath)) { // Add all scanned mods that have not been added to the load order yet Log.Information("Scanning for mods..."); - - mods.AddRange(ScanMods().Where(n => !mods.Any(m => EqualModNames(m.Name, n))) - .Select(m => new ModInfo(m, defaultEnabled))); + + foreach (var newMod in ScanMods().Where(newMod => !mods.Contains(newMod))) { + mods.Add(newMod); + } Log.Information("Found {ModsCount} mods.", mods.Count); } + + Log.Information("Enabled Mods:"); + + foreach (var mod in mods.Where(x => x.Enabled)) { + Log.Information(" {ModName}", mod.Name); + } } if (!GamePath.IsXbox(Path.Combine(GamePath.FullGamePath)) || !_iniData.TryGetKey("Overrides.RebuildMLO", out _)) @@ -286,11 +293,11 @@ internal static List PreRun() { _iniData.Sections["Overrides"]["RebuildMLO"] = "0"; IniParser.WriteFile(Constants.INI, _iniData); RebuildMlo = false; - + return mods; } - internal static async Task RunGeneration(List mods) { + internal static async Task RunGeneration(List mods) { if (File.Exists(Constants.MLO)) { Log.Information("Removing old MLO..."); @@ -309,8 +316,14 @@ internal static async Task RunGeneration(List mods) { if (!mods.IsNullOrEmpty() || _looseFilesEnabled) { // Create Parless mod as highest priority - mods.Remove("Parless"); - mods.Insert(0, "Parless"); + var parlessEntry = mods.FirstOrDefault(x => string.Equals(x.Name, "Parless", StringComparison.InvariantCultureIgnoreCase)); + + if (parlessEntry != null) { + mods.Remove(parlessEntry); + mods.Insert(0, parlessEntry); + } else { + mods.Insert(0, new ModInfo("Parless", 0)); + } Directory.CreateDirectory(Constants.PARLESS_MODS_PATH); @@ -400,127 +413,40 @@ private static void PostRun() { } } - private static List ReadModLoadOrderTxt(string txt) { - if (!File.Exists(txt)) { - return []; - } - - var mods = new HashSet(); - - foreach (var line in File.ReadLines(txt)) { - if (line.StartsWith(';')) - continue; - - var sanitizedLine = line.Split(';', 1)[0].Trim(); - - // Add only valid and unique mods - if (!string.IsNullOrEmpty(sanitizedLine) && - Directory.Exists(Path.Combine(GamePath.MODS, sanitizedLine))) { - mods.Add(sanitizedLine); - } - } - - return mods.ToList(); - } - - private static List ConvertOldToNewModList(List mods) { - return mods.Select(m => new ModInfo(m)).ToList(); - } - - internal static List ConvertNewToOldModList(List mods) { - return mods.Where(m => m.Enabled).Select(m => m.Name).ToList(); - } - internal static bool ShouldBeExternalOnly() { return _externalModsOnly && Directory.Exists(GamePath.ExternalModsPath); } - - private static List ScanMods() { - return Directory.GetDirectories(GamePath.ModsPath) - .Select(d => Path.GetFileName(d.TrimEnd(Path.DirectorySeparatorChar))) - .Where(m => !string.Equals(m, "Parless") && !string.Equals(m, Constants.EXTERNAL_MODS)) - .ToList(); - } - - private static bool EqualModNames(string m, string n) { - return string.Compare(m, n, StringComparison.InvariantCultureIgnoreCase) == 0; - } - - internal static bool MissingDll() { - return !(File.Exists(Constants.DINPUT8DLL) || File.Exists(Constants.VERSIONDLL) || File.Exists(Constants.WINMMDLL)); - } - internal static bool MissingAsi() { - return !File.Exists(Constants.ASI); - } - - public static List ReadModListTxt(string text) { + private static List ScanMods(ProfileMask activeProfiles = ProfileMask.All) { var mods = new List(); - - if (!File.Exists(text)) { - return mods; - } - - using var file = new StreamReader(new FileInfo(text).FullName); - var line = file.ReadLine(); - - if (line == null) - return mods; - - foreach (var mod in line.Split('|', StringSplitOptions.RemoveEmptyEntries)) { - if (!mod.StartsWith('<') && !mod.StartsWith('>')) + + foreach (var dir in Directory.EnumerateDirectories(GamePath.ModsPath)) { + var modPath = Path.GetFileName(dir.TrimEnd(Path.DirectorySeparatorChar)); + + if (string.Equals(modPath, "Parless") || string.Equals(modPath, Constants.EXTERNAL_MODS)) continue; - var info = new ModInfo(mod[1..], mod[0] == '<'); - - if (ModInfo.IsValid(info) && !mods.Contains(info)) { - mods.Add(info); - } + var entry = new ModInfo(modPath, activeProfiles); + + if (mods.Contains(entry)) + continue; + + mods.Add(entry); } return mods; } - internal static async Task SaveModListAsync(List mods) { - var result = await WriteModListTextAsync(mods); - - if (!_migrated) - return result; - - try { - File.Delete(Constants.TXT_OLD); - - var iniParser = new FileIniDataParser(); - iniParser.Parser.Configuration.AssigmentSpacer = string.Empty; - - var ini = iniParser.ReadFile(Constants.INI); - - ini.Sections.AddSection("SavedSettings"); - ini["SavedSettings"].AddKey("ModListImported", "true"); - iniParser.WriteFile(Constants.INI, ini); - } catch { - Log.Warning($"Could not delete {Constants.TXT_OLD}. This file should be deleted manually."); - } - - return result; + internal static bool MissingDll() { + return !(File.Exists(Constants.DINPUT8DLL) || File.Exists(Constants.VERSIONDLL) || File.Exists(Constants.WINMMDLL)); } - private static async Task WriteModListTextAsync(List mods) { - if (mods.IsNullOrEmpty()) - return false; - - var sb = new StringBuilder(); - - foreach (var mod in mods) { - sb.Append($"{(mod.Enabled ? '<' : '>')}{mod.Name}|"); - } - - // Remove leftover pipe - sb.Length -= 1; - - await File.WriteAllTextAsync(Constants.TXT, sb.ToString()); + internal static bool MissingAsi() { + return !File.Exists(Constants.ASI); + } - return true; + internal static void SaveModList(List mods) { + ModListSerializer.Write(Constants.MOD_LIST, mods); } public static string[] GetModDependencies(string mod) { @@ -620,16 +546,14 @@ private static async Task DownloadLibraryPackageAsync(string fileName, L } } - public static async Task InstallAllModDependenciesAsync() { + public static async Task InstallAllModDependenciesAsync(List mods) { try { await LibMeta.FetchAsync(); } catch { // ignored } - var modList = ReadModListTxt(Constants.TXT); - - foreach (var mod in modList.Where(x => x.Enabled)) { + foreach (var mod in mods.Where(x => x.Enabled)) { await InstallModDependenciesAsync(mod.Name); } } diff --git a/ShinRyuModManager-CE/ShinRyuModManager-CE.csproj b/ShinRyuModManager-CE/ShinRyuModManager-CE.csproj index 40180bf..d08e41d 100644 --- a/ShinRyuModManager-CE/ShinRyuModManager-CE.csproj +++ b/ShinRyuModManager-CE/ShinRyuModManager-CE.csproj @@ -14,7 +14,7 @@ true - 1.3.8 + 1.4.0 $(AssemblyVersion) ShinRyuModManager-CE SRMM Studio @@ -47,10 +47,10 @@ + - diff --git a/ShinRyuModManager-CE/UserInterface/Assets/changelog.md b/ShinRyuModManager-CE/UserInterface/Assets/changelog.md index 7979de4..54d8af1 100644 --- a/ShinRyuModManager-CE/UserInterface/Assets/changelog.md +++ b/ShinRyuModManager-CE/UserInterface/Assets/changelog.md @@ -1,4 +1,13 @@ -> ### **%{color:gold} Version 1.3.8 %** ### +> ### **%{color:gold} Version 1.4.0 %** ### +* Added multiple profiles +* Updated mod list save file to version 2 + * New file name is `ModList.ml` + * Old mod list will be appended `.bak` in case it needs to be recovered +* Re-added auto running game option in CLI mode + +--- + +> ### **%{color:orange} Version 1.3.8 %** ### * Fixed MLO generation --- diff --git a/ShinRyuModManager-CE/UserInterface/Updater/AutoUpdating.cs b/ShinRyuModManager-CE/UserInterface/Updater/AutoUpdating.cs index 0793815..ae343a1 100644 --- a/ShinRyuModManager-CE/UserInterface/Updater/AutoUpdating.cs +++ b/ShinRyuModManager-CE/UserInterface/Updater/AutoUpdating.cs @@ -18,6 +18,8 @@ public static class AutoUpdating { private static string _tempDir; private static SparkleUpdater _updater; + private static readonly string[] IgnoredBuilds = ["debug", "test"]; + public static void Init() { _tempDir = Path.Combine(Environment.CurrentDirectory, "srmm_temp"); @@ -31,7 +33,7 @@ public static void Init() { var suffix = AssemblyVersion.GetBuildSuffix(); - if (string.Equals(suffix, "debug", StringComparison.OrdinalIgnoreCase)) { + if (IgnoredBuilds.Contains(suffix, StringComparer.OrdinalIgnoreCase)) { return; // Don't need to be annoyed with "Update Now" when debugging } diff --git a/ShinRyuModManager-CE/UserInterface/ViewModels/MainWindowViewModel.cs b/ShinRyuModManager-CE/UserInterface/ViewModels/MainWindowViewModel.cs index 4c4e689..6c49fe4 100644 --- a/ShinRyuModManager-CE/UserInterface/ViewModels/MainWindowViewModel.cs +++ b/ShinRyuModManager-CE/UserInterface/ViewModels/MainWindowViewModel.cs @@ -1,5 +1,9 @@ using System.Collections.ObjectModel; +using Avalonia.Input; using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Serilog; +using ShinRyuModManager.Extensions; using ShinRyuModManager.ModLoadOrder.Mods; using Utils; @@ -26,23 +30,27 @@ public void SelectMod(ModMeta mod) { ModAuthor = mod.Author; ModVersion = mod.Version; } + + [RelayCommand] + private void UpdateProfile(Profile profile) { + Program.ActiveProfile = profile; + + Log.Information("Setting Profile to "); + + LoadModList(profile); + } private void Initialize() { - TitleText = $"Shin Ryu Mod Manager [{GamePath.GetGameFriendlyName(GamePath.CurrentGame)}]"; AppVersionText = $"v{AssemblyVersion.GetVersion()}"; - - // Prefer launching through Steam, but if Windows, allow launching via exe - if (GamePath.IsSteamInstalled()) { - GameLaunchPath = $"steam://launch/{GamePath.GetGameSteamId(GamePath.CurrentGame)}"; - } else if (OperatingSystem.IsWindows()) { - GameLaunchPath = GamePath.GameExe; - } + GameLaunchPath = GamePath.GetGameLaunchPath(); Directory.CreateDirectory(GamePath.MODS); Directory.CreateDirectory(GamePath.LIBRARIES); } - internal void LoadModList() { - ModList = new ObservableCollection(Program.PreRun()); + internal void LoadModList(Profile? profile = null) { + ModList = new ObservableCollection(Program.PreRun(profile)); + + TitleText = $"Shin Ryu Mod Manager [{GamePath.GetGameFriendlyName(GamePath.CurrentGame)}] [{Program.ActiveProfile.GetDescription()}]"; } } \ No newline at end of file diff --git a/ShinRyuModManager-CE/UserInterface/Views/ChangeLogWindow.axaml b/ShinRyuModManager-CE/UserInterface/Views/ChangeLogWindow.axaml index 63c7669..7e063a1 100644 --- a/ShinRyuModManager-CE/UserInterface/Views/ChangeLogWindow.axaml +++ b/ShinRyuModManager-CE/UserInterface/Views/ChangeLogWindow.axaml @@ -27,7 +27,7 @@ * - +